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.
| Key | Meaning |
|---|---|
board | The 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 |
launch | The launch file this image bakes, relative to the Bringup pkg. Absent ⇒ [system] default_launch |
args | Launch arguments bound at resolve time ({ host = "robot1" }) — how an image selects one machine out of a multi-host launch tree |
rmw | Backend for this image. Absent ⇒ [system] rmw |
panic | RFC-0077 panic policy (platform | halt | own), forwarded to the generated entry |
profile | A cargo / CMake build profile name |
conf | Extra framework config fragments, in order (Zephyr prj-*.conf) |
features | Capability axes for this image, over [system]’s list |
entry | The 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:
| driver | where the entry lands | what roots the build |
|---|---|---|
| cargo | a real package at <ws>/build/<coordinate>/<image_id>_entry/, path-depending on your Node pkgs | its own Cargo.toml. That IS the cargo root; the binary lands at …/<image_id>_entry/target/[<triple>/]<profile>/<image_id>_entry |
| cmake | a nano_ros_add_executable(…) call in <ws>/build/<coordinate>/CMakeLists.txt, one per image sharing the coordinate | that 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 —
| part | derived from |
|---|---|
the no_std / no_main shell | the 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 entry | goes to |
|---|---|
| board, launch file, launch args, RMW, panic policy, profile | the [image.*] row |
RTOS config overlays — prj.conf, boards/*.overlay, sdkconfig.defaults | the Bringup pkg, under boards/<board>/ |
| genuinely hand-written startup you cannot express as a declaration | nros 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
- Bringup packages — the
system.tomland launch XML an image is declared in. - Node packages — the Node pkgs an image links.
- Zephyr (west module) — the exception, in full.
- Role reference — reference for the package roles and the macro forms.