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

Images

An image is one buildable program: a topology, a board, and a backend. You declare it as a row in your Bringup pkg’s system.toml —

[image.linux]
board = "linux"

— and build it by name:

nros sync
nros build linux

There is no package to write. nros build discovers your Node pkgs, reads the [image.*] table, and generates the entry that boots the topology, under build/. An image is a row, not a directory.

That is the whole model. The rest of this page is what goes in the row, where the generated pieces land, and the one case that still needs a package of its own.

Prereqs

Pick one path from a fresh checkout:

A. Front door (bare machine OK — no Rust):

./scripts/bootstrap.sh

Installs rustup if needed and builds the in-tree nros CLI from source at packages/cli/target/release/nros, leaving it on PATH for this shell (in a checkout, the tree’s own build is the binary this tree accepts — RFC-0090 / phase-431).

B. Already have cargo (equivalent — same build, same binary):

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

Every subsequent shell sources the workspace env via one of:

direnv allow                  # if you use direnv
source ./activate.sh          # bash / zsh
source ./activate.fish        # fish

Then provision the native host (the canonical first image target; for Zephyr / FreeRTOS / ESP32 swap in the matching nros setup board):

nros setup linux --rmw zenoh
nros sync                     # generated message bindings, once per workspace

What a workspace looks like

The four reference workspaces in examples/workspaces/ all have the same shape. Node pkgs, one Bringup pkg, and nothing else:

examples/workspaces/c/
├── .colcon_workspace         # the tracked marker: this directory IS a workspace root
├── .gitignore
├── README.md
└── src/
    ├── talker_pkg/           # Node pkg
    ├── listener_pkg/         # Node pkg
    ├── service_server_pkg/   # Node pkg
    ├── … three more …
    ├── demo_bringup/         # Bringup pkg: package.xml + system.toml + launch/
    └── zephyr_entry/         # the one exception — see below

No CMakeLists.txt, no Cargo.toml at the root — and none is generated there either. A workspace is a directory of packages, exactly as a colcon workspace is, and everything the build produces lives under build/, dist/ and log/ (RFC-0098 D9). The tracked marker that this directory is a workspace root is .colcon_workspace.

And no entry packages, except the Zephyr one. Fourteen images ship out of that directory. nros build with no argument lists them:

$ cd examples/workspaces/c
$ nros build
Error: this workspace declares 14 images and no default.

  demo_bringup:freertos
  demo_bringup:freertos_posix
  demo_bringup:linux
  demo_bringup:linux_action_client
  demo_bringup:linux_action_server
  demo_bringup:linux_cyclonedds
  demo_bringup:linux_robot1
  demo_bringup:linux_robot2
  demo_bringup:linux_service_client
  demo_bringup:linux_service_server
  demo_bringup:linux_xrce
  demo_bringup:nuttx
  demo_bringup:threadx
  demo_bringup:zephyr

  build one:   nros build freertos
  build all:   nros build --all
  or declare:  [system] default_images = ["freertos"]

Fourteen programs, one launch tree, six node packages, one entry directory. Under the old shape that was fourteen directories.

Declaring an image

Images live in the Bringup pkg beside the topology they boot — examples/workspaces/c/src/demo_bringup/system.toml:

[image_defaults]
rmw = "zenoh"

[image.linux]
board = "linux"

[image.freertos]
board = "mps2-an385-freertos"

[image.freertos_posix]
board = "freertos-posix"
# NOT the `[image_defaults]` zenoh. zenoh-pico's FreeRTOS backend is lwIP-only
# and this board has no lwIP.
rmw = "cyclonedds"

[image.linux_service_server]
board = "linux"
launch = "service_server.launch.xml"

[image.linux_robot1]
board = "linux"
launch = "multihost.launch.xml"
args = { host = "robot1" }

[image.zephyr]
board = "zephyr"
conf = ["prj-zenoh.conf"]

Read it as a table of variants over one topology. linux and linux_cyclonedds differ in a backend; linux and linux_service_server differ in a launch file; linux_robot1 and linux_robot2 differ in one launch argument. Nothing is duplicated to express any of that.

KeyMeaning
boardThe nano-ros board id — resolved through packages/boards/board-support.toml, which supplies the rustc triple, the platform, and the framework’s own board string
launchThe launch file this image bakes, relative to the Bringup pkg. Absent ⇒ [system] default_launch
argsLaunch arguments bound at resolve time ({ host = "robot1" }) — how an image selects one machine out of a multi-host launch tree
rmwBackend for this image. Absent ⇒ [system] rmw
panicRFC-0077 panic policy (platform | halt | own), forwarded to the generated entry
profileA cargo / CMake build profile name
confExtra framework config fragments, in order (Zephyr prj-*.conf)
featuresCapability axes for this image, over [system]’s list
entryThe application package — normally omitted; see the exception

Switching a workspace image to another board is that one board line and nros sync — nothing in any package you wrote mentions a board crate, because the entry that depends on it is generated (RFC-0098 D6). A single-package leaf is its own entry and does not yet get that: see Role reference.

[image_defaults] is the base every block folds over (RFC-0065 D5.1): scalars are replaced by the specific block, args merges, conf and features concatenate. Without it an eight-image workspace repeats its RMW eight times, and eight copies of one fact is how they start disagreeing.

[system] default_images = ["linux"] is what a bare nros build then builds.

What nros build generates

Everything it generates lands under build/ — build output, gitignored, none of it yours to edit:

driverwhere the entry landswhat roots the build
cargoa real package at <ws>/build/<coordinate>/<image_id>_entry/, path-depending on your Node pkgsits own Cargo.toml. That IS the cargo root; the binary lands at …/<image_id>_entry/target/[<triple>/]<profile>/<image_id>_entry
cmakea nano_ros_add_executable(…) call in <ws>/build/<coordinate>/CMakeLists.txt, one per image sharing the coordinatethat generated CMakeLists.txt; the binary lands in <ws>/build/<coordinate>/cmake/

An earlier shape kept the cargo root at <ws>/Cargo.toml, on the grounds that cargo resolves a package’s workspace by walking up and refuses a member above its root. That constraint binds only while a workspace exists: with no root manifest anywhere there is nothing to walk up to, and a path dependency need not sit below anything. So the generated entry is the root, and the workspace keeps no build file of its own (RFC-0098 D9, superseding RFC-0065 D3).

Beside the generated entry sits nros-cargo.toml, the settings file nros sync writes per image (RFC-0098 D1): the board’s rustc triple and link flags, that image’s target-dir, the resolved [env], and the in-repo patch rows. Cargo reads it through --config, which is why each image compiles with its own settings and nothing generated ever appears beside a package — no leaf .cargo/config.toml, no board projection to commit.

The coordinate is the platform and the RMW, plus — for cmake, which pins one compiler per configure — the board. So [image.linux] in examples/workspaces/c configures build/posix-zenoh-linux/ into build/posix-zenoh-linux/cmake/ and leaves its binary at build/posix-zenoh-linux/cmake/linux_entry. On the cargo driver nros build names the entry package it wrote as it goes:

nros build:   entry → …/build/posix-zenoh/linux_entry

The generated cmake root lists every image on that coordinate, not just the one being built: what a coordinate contains is a property of the workspace, and making it depend on which image you asked for would reconfigure on every switch.

An image with its own configuration gets its own configure

One cmake configure builds the nano-ros runtime (nros-c / nros-cpp and the RMW) once for every image on it, so a knob only one image states — a [image.<id>.knobs] row such as [knobs.log] max_level = "warn" — would have no build to land in. So an image whose resolved configuration differs gets its own configure, keyed by a hash of that configuration (RFC-0098 D12, phase-481 W4):

[image.plain]            # states nothing -> build/posix-zenoh-linux/
board = "linux"

[image.warn]             # -> build/posix-zenoh-linux-cfga50aa93c83/
board = "linux"

[image.warn.knobs.log]
max_level = "warn"

[image.warn.knobs.executor]
max_cbs = 13

(packages/testing/nros-tests/fixtures/image_config_ws; each binary prints what its runtime compiled in.) Images whose configuration is the SAME share one configure, -cfg suffix and all, and a workspace whose images state nothing keeps today’s build/<coordinate>/ paths exactly. The cost is one more configure and one more runtime build per distinct configuration — the reason to put a knob that every image on a board shares in the board’s [knobs] instead, where it changes no image’s hash. nros image-facts prints each image’s cmake_build_dir; never derive it by hand.

On Zephyr the same env rows are rendered into the image’s Kconfig fragment instead (see Configuring an image).

The generated entry is the same few lines you would have written. RFC-0065 D4 measured them before deciding to derive them: every Rust entry in the tree was ≤ 6 non-comment lines, every embedded C/C++ entry had zero source files. Three parts, all derivable —

partderived from
the no_std / no_main shellthe board’s entry_kind
board boilerplate (use panic_semihosting as _;, esp_app_desc!())the board descriptor’s entry.crate_root_extra
nros::main!(launch = …, args = …)the image

— which is why adding a board adds a descriptor rather than a branch in the emitter, and why the generated entry calls nros::main! rather than its own expansion: the macro reads the launch XML at expansion time, so adding a node to the launch file is picked up by the next compile with nothing regenerated.

The exception: when a framework owns the application

An entry package survives in exactly one situation — an external build system that demands a real application directory. That is Zephyr.

A Zephyr app is the app target that find_package(Zephyr) creates, and it carries authored Kconfig that nothing can derive: prj.conf, the per-RMW prj-<rmw>.conf fragments, boards/*.overlay. RFC-0065 D5 draws the line there — “west and ESP-IDF apps keep their own files because those are Kconfig overlays — user intent, not derivable.”

The Kconfig is the exception; the application is not (issue 1288). nros build writes the west application too — build/<coord>/<image>_entry/ CMakeLists.txt, with find_package(Zephyr), project(), one add_subdirectory per node package your launch file names, and the single nano_ros_add_executable(...) (or rust_cargo_application() for a Rust image) — and then runs west build against it. What you author is the Kconfig, and it lives with the bringup:

src/demo_bringup/boards/native_sim_native_64/
├── prj.conf                            # APPLICATION_CONFIG_DIR
├── prj-zenoh.conf                      # named by [image.<id>] conf
└── boards/
    └── native_sim_native_64.conf       # Zephyr's own per-board discovery

That directory is what [image.<id>] board selects, and switching boards selects a different one rather than editing a package. Every Zephyr image in examples/workspaces/{rust,c,cpp,mixed,derived-tiers-cpp,realtime-c,realtime-cpp} is built this way. realtime-c shows the two-bringup case: demo_bringup declares [image.zephyr] (native_sim) and smp_bringup declares [image.zephyr_smp] (qemu-cortex-a53), so each image names its own bringup and gets its own generated application and its own boards/<board>/ directory.

A board PACKAGE can carry Zephyr files of its own beside its descriptor — a base prj.conf, boards/<board>.conf and boards/<board>.overlay. nros build hands those to the generated application as the first EXTRA_CONF_FILE / EXTRA_DTC_OVERLAY_FILE entries, by the same rule nano_ros_use_board() applies to a hand-written one. They merge AFTER the bringup’s prj.conf (Zephyr’s CONF_FILE) and BEFORE the image’s conf fragments, so a value that must beat the board goes in a conf fragment. realtime-cpp’s [image.fvp] (fvp-aemv8r-smp) is the in-tree case: it lists its own prj.conf in conf again so that its CONFIG_RUST=n lands after the board’s CONFIG_RUST=y.

For a Rust workspace whose image needs a hand-written application after all — Kconfig nros build cannot express, a nano_ros_use_board() preamble — the scaffold is still there:

nros new entry zephyr_entry --platform zephyr

That writes Cargo.toml, CMakeLists.txt and prj.conf, and the [image.*] row — the two halves are one declaration, and every Zephyr build failure worth having is the two disagreeing. A package written this way SUPPRESSES generation for the image that would carry its name, which is what makes the migration in either direction a file operation rather than a flag. Zephyr is the only platform the verb accepts, which is the rule stated by the tool itself:

`nros new entry` currently scaffolds Zephyr entries only (got --platform linux).
Every other platform builds through cargo or cmake, where the entry is
GENERATED from the image and needs no package of its own (RFC-0065 D3).

Running on Zephyr

The Zephyr image names its application and its RMW overlay; nothing else in the row is Zephyr-specific:

[image.zephyr]
board = "zephyr"
conf  = ["prj-zenoh.conf"]
nros sync                              # generated message bindings, once
nros build demo_bringup:zephyr         # resolves the app + overlays, runs west build
nros build demo_bringup:zephyr -- -t run

Plain west keeps working and stays the primary flow — nros build is the convenience that applies an image’s overlays for you, never a required layer between you and west:

west build -b native_sim/native/64 build/zephyr-zenoh/zephyr_entry \
    -- -DAPPLICATION_CONFIG_DIR=src/demo_bringup/boards/native_sim_native_64
west build -t run                      # native_sim; `west flash` for hardware

(nros build demo_bringup:zephyr --dry-run prints that command for your image, which is the reliable way to get it: the application path carries the coordinate, and --dry-run reads it off the image rather than asking you to spell it.)

One workspace covers every Zephyr board. Zephyr owns its board abstraction, so the board is chosen by the image’s board key and never baked into a package. That key is a nano-ros board id (zephyr), never Zephyr’s own spelling: its board descriptor states the Zephyr id ([board.zephyr] west_board = "native_sim/native/64"), and that is what west build -b receives. Writing board = "native_sim/native/64" on the image is refused, naming the id to use. To target another Zephyr board, name a descriptor that states its west_board — nrf52840dk/nrf52840, say, from a board package in your workspace — and swap prj-zenoh.conf for prj-xrce.conf: what changes is which boards/<board>/ directory is read, and nothing in src/ at all.

The entry key exists for the case where several packages could answer to one image. An application package no longer declares the board it serves — that direction is reversed: the image claims the entry, by naming it (entry = "zephyr_entry") or, when it names none, by being the image whose generated entry would carry that name (RFC-0098 D5, phase-445 W5). So the application reads its deployment — board, RMW, locator, domain — off the image that claims it, and a [package.metadata.nros.entry] deploy left in a Cargo.toml is now an error naming the file to write instead.

Two images claiming one entry is refused rather than resolved by first match — which image’s locator the entry bakes is not a coin toss, so nros build names the candidates instead of picking one. That arm is reachable for C/C++ entries, which are matched by their DEPLOY token; a Rust entry names its image in its own system.toml, so it is never a candidate for a sibling image.

The other reason to set entry is that NO package claims the board — then nros build falls back to the Bringup directory and you see a conf fragment “not found” in a path that is not your application’s. A hand-written application that takes its board from nano_ros_use_board(...) in its own CMakeLists.txt is that case, because the DEPLOY scan does not read the call (issue 1517). Everywhere else, leave entry out and let the name match.

The full Zephyr path — west workspaces, freestanding applications, west nros, sysbuild — is Zephyr (west module).

If you already have entry packages

nros build keeps a hand-written entry: if src/<image_id>_entry/ carries a Cargo.toml or a CMakeLists.txt, it is used as-is and nothing is generated over it. Migration is therefore a deletion, one image at a time — remove the directory, and the next build generates its replacement.

Move these out first, since deleting the package deletes them too:

in the old entrygoes to
board, launch file, launch args, RMW, panic policy, profilethe [image.*] row
RTOS config overlays — prj.conf, boards/*.overlay, sdkconfig.defaultsthe Bringup pkg, under boards/<board>/
genuinely hand-written startup you cannot express as a declarationnros materialize, which writes the package back out for you to keep

nros::main!() — what the generated entry contains

You will read this in a generated entry, and write it by hand only in a single-package project or a materialized entry.

#![allow(unused)]
fn main() {
// 1. Single-node self-bringup: reads the board from the `[image.*]` row in
//    the `system.toml` beside this package's Cargo.toml, and boots the Node
//    pkg that is the only member of this package.
nros::main!();

// 2. RETIRED (phase-484 W8c): `nros::main!(board = LinuxBoard)` is a
//    compile error. The board belongs in `system.toml`; an out-of-tree board
//    crate states its own type in
//    `[package.metadata.nros.board] type = "::my_board::MyBoard"`, which
//    form 1 reads.

// 3. Multi-node (CANONICAL): name your INPUT — the Bringup pkg, and
//    optionally a launch file inside it; the default is its default launch.
nros::main!(launch = "demo_bringup");
nros::main!(launch = "demo_bringup:variant_b.launch.xml");

// 4. Multi-host slice: the launch file gates nodes on a `host` launch
//    argument (`if=` conditions), and the model is resolved with
//    `host:=robot1`, so it already contains only this host's nodes.
nros::main!(launch = "demo_bringup:multihost.launch.xml", args = [("host", "robot1")]);

// 5. DEPRECATED (expert override): name the resolved model ARTIFACT
//    directly instead of the input. Skips the sync-freshness contract;
//    warns at bake time (phase-330 W7).
nros::main!(model = "demo_bringup");
}

launch and model are mutually exclusive. launch is the canonical spelling: it names your input — the bringup package and launch file — and the entry consumes the SystemModel that nros sync resolves from it. The same resolved artifact drives the Linux runtime (play_launch) and every embedded image, so contract budgets, tiers, and QoS never drift between runtimes.

$ nros sync    # resolves models into <ws>/build/nros/models/<bringup>/

nros sync re-resolves a model whenever a bringup’s launch XML or system.toml is newer than it, and nros build runs it for you. SystemModels are build artifacts — never commit one (the check-no-tracked-models gate enforces this). Under the hood nros sync runs the pinned nros-launch-resolve helper (RFC-0060 layer 2) by absolute path, never by a bare name on $PATH, because an unrelated ROS 2 play_launch on PATH used to win that race.

The macro reads the image’s board at compile time to select the board crate and executor backend, and the same model resolves its tier table for whichever RTOS that board targets. On Embassy / RTIC targets it emits the framework-specific #[embassy_executor::main] or #[rtic::app] body, so the crate root stays a single line — as in examples/mps2-an385-baremetal/rust/talker-rtic/src/main.rs:

#![allow(unused)]
#![no_std]
#![no_main]

fn main() {
use panic_semihosting as _;

nros::main!();
}

Escape hatch

If you need more control than the macro provides — custom startup ordering, hardware init before the runtime, or a fully manual executor loop — take ownership of the generated entry:

$ nros build linux          # generate it first — materialize copies, it does not re-emit
$ nros materialize linux
wrote …/src/linux_entry

This entry is YOURS now — `nros build` will not regenerate it.

It lands at src/<image_id>_entry/, carries a stamp recording the shape it was cut for, and nros build warns if that shape later moves. Its derivation stays live — nros::main! still reads the launch file at compile time, so adding a node needs no change here; what is frozen is the shell: the panic policy, the board boilerplate, the crate type. Inside it you can bypass nros::main!() entirely:

#![allow(unused)]
fn main() {
// Option A: delegate init to the board crate, supply your own closure.
<LinuxBoard as BoardEntry>::run(|runtime| {
    let node = runtime.create_node("talker", "/", &Default::default())?;
    // ...
    Ok(())
});

// Option B: fully manual — no board crate.
let executor = nros::Executor::open(&ExecutorConfig::default())?;
// wire nodes, spin manually ...
}

Option A is the right choice when you need to run something before the first spin (e.g. DMA setup, flash unlock). Option B is there for board-bringup authors adding a new platform. Reach for either only after the declarative escapes — an [image.*] key, a conf fragment, a support package — have actually run out.

Running a native image

Build it, start a router if your backend needs one, run the binary:

cd examples/workspaces/c
nros sync
nros build linux

# in another shell, for the zenoh backend:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd &

./build/posix-zenoh-linux/cmake/linux_entry

The binary opens the executor, registers talker + listener composed into a single process, and runs the topology. The launch product is the binary — there is no separate launch step.

examples/workspaces/{c,cpp,rust,mixed}/ are the four canonical workspaces and each carries a README with the commands verified green today.

C / C++ images

Nothing above is Rust-specific: the driver is chosen by the board, not by the language mix, and cmake wins whenever the package graph crosses languages (corrosion makes cargo consumable from cmake; nothing makes cmake consumable from cargo). examples/workspaces/c and examples/workspaces/cpp declare their images exactly as shown here. See C / C++ multi-node workspaces.

Where to go next