Multi-Node Project Layout
You built the single-file talker in
examples/linux/rust/talker/:
one main.rs, one Cargo.toml, one package — open a terminal, run
cargo run, done. That shape covers a lot of ground: demos, driver
tests, single-purpose microcontroller apps.
At some point it stops being enough.
When you outgrow one app
Three common triggers:
-
Two or more nodes. You want a talker and a listener, or a sensor driver alongside a control loop. Splitting them into one entry binary per node file works until you need to tune their wiring or deploy them to different boards.
-
A shared launch topology. You want to describe once how nodes are named, remapped, and parameterised — and reuse that description across a dev laptop, a hardware bring-up board, and a sim target.
-
Multiple deploy targets. The same talker logic goes on a native Linux host for integration testing and on an STM32F4 for production. The node logic is identical; only the boot and board differ.
-
Mixed implementation languages. You want to keep a C driver or legacy C node alongside C++ or Rust ones. The Node-pkg register ABI is language-neutral, so a C Node pkg links into the same binary as C++ or Rust Node pkgs.
That’s when you split your project into a multi-node workspace.
Canonical layout
Start with the whole project before diving into the parts:
my_robot_ws/
├── .colcon_workspace # the tracked marker: this directory IS a workspace root
└── src/
├── talker_pkg/ # Node pkg: reusable node logic, no main()
├── listener_pkg/ # Node pkg: another reusable node
└── robot_bringup/ # Bringup pkg: launch XML + system.toml + [image.*]
Two kinds of package, and that is the whole tree. A workspace has no root
build file at all — no Cargo.toml, no CMakeLists.txt — the way a colcon
workspace is just a directory of packages (RFC-0098 D9). There is no binary
package to write either: from the [image.*] table in the Bringup pkg,
nros build generates the entry and the build root that compiles it, both
under build/. The cargo root is the generated entry itself,
build/<coordinate>/<entry>/Cargo.toml; the cmake root is
build/<coordinate>/CMakeLists.txt. Nothing generated is ever written beside
your source.
| Role | Owns | Does not own |
|---|---|---|
| Node pkg | Publishers, subscriptions, timers, services, actions, callback bodies | Board choice, launch topology, main() |
| Bringup pkg | Which nodes run, names, remaps, parameters — and the [image.*] rows saying which programs get built out of them | Compiled code |
A typical product has many Node pkgs, one Bringup pkg per logical system, and
one [image.*] row per board or deploy target. The same talker_pkg and
listener_pkg are linked into a native host image for integration testing and
a Cortex-M image for hardware, with nothing duplicated between them.
Reading order
This group starts broad and then drills into each part:
- Project layout — this page: when to split and how the roles fit.
- Node packages — reusable node libraries with
nros::node!. - Bringup packages — launch XML,
system.toml, remaps, parameters. - Images — the
[image.*]row that becomes a program, and the one platform that still needs a package of its own. - C / C++ multi-node workspaces — the same structure through CMake.
- Mixed-language workspaces — C Node pkgs beside C++ and Rust ones.
- Role reference — metadata fields and macro forms in reference style.
Prereqs
Pick one path from a fresh checkout — just is NOT a prereq.
A. Front door (bare machine OK — no Rust, no just):
./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:
nros setup linux --rmw zenoh
The three roles in practice
Node pkg — a lib crate that contains one node’s logic. It
declares nros::node!(T); its Cargo.toml carries Rust-toolchain facts
only, and the node’s identity — which package, which class, what it is
called — is a [[component]] row in the Bringup pkg’s system.toml. It
has no fn main() — no package you write has one. One Node pkg per node.
Think of it as a composable building block: the same talker_pkg lib
can be assembled into a native binary and an embedded binary without
any source change.
Bringup pkg — a purely declarative directory that owns the launch
topology and the images built from it. It contains a package.xml, a
system.toml (which nodes run, how they’re wired, and an [image.<id>]
row per program), and a launch/ directory with ROS 2 launch XML. No
Cargo.toml, no compiled code. Naming convention <system>_bringup,
matching nav2 / Autoware / turtlebot3.
Image — not a package. [image.linux] board = "linux" is a row in
that system.toml, and nros build linux turns it into a program: it
generates, under build/, the entry that links the Node pkgs and the board
crate and hands control to the nano-ros runtime. A second board is a second
row, not a second directory.
Two commands, in this order, and they are the same two everywhere in this group:
nros sync # generated message bindings + the generated build settings
nros build # every [image.*]; or name one: nros build linux
nros sync is yours to run once per workspace, and again whenever you edit
a .msg or move the checkout. A workspace that has never been synced makes
nros build stop at preflight with one line naming it.
The app-node shape you already know (examples/linux/rust/talker/) is a
single package that is both the logic and the boot point — no bringup and
no entry, but the same system.toml beside its Cargo.toml, stating its
board in an [image.*] row (RFC-0098 D3). nros::main!() reads it. That
fusion is fine — and encouraged — for single-node work. Only split when you
actually need the flexibility.
ROS 2 ↔ nano-ros command map
If you’re coming from ROS 2, here’s the mapping of the commands you already know:
| ROS 2 | nano-ros | Notes |
|---|---|---|
ros2 pkg create | nros new node <name> | scaffolds a Node pkg and adds its [[component]] row |
colcon build | nros sync once, then nros build <image> | resolves the image, generates the entry and its build root under build/, hands off to cargo / cmake / west / idf.py |
ros2 launch <pkg> <file> | run the image’s binary | the image IS the launch product; the old launch wrapper was removed in nros 0.5.0 |
| (plan/validate) | nros plan → nros check | resolve + statically check the topology |
ros2 run <pkg> <exe> | run the image’s binary | one [image.*] row per board |
nros build hands off rather than taking over: it generates what the build
system needs and then runs your platform’s own tool (cargo, cmake, west,
idf.py), so compiler errors stay the compiler’s, unchanged. Running the tool
yourself keeps working. One image = one binary = one process; multi-process
orchestration (the equivalent of several ros2 launch nodes in separate
processes) is several images plus a shell script or tmux session, not a CLI
verb.
The app-node shape stays valid
There is no obligation to restructure. If your project is one node on
one board, the app-node shape (src/main.rs + one package = both
logic and boot) is perfectly idiomatic and has no runtime penalty. The
three-role split is a tool for when you need it, not a gatekeeping
requirement.
Where to go next
Walk through the multi-node project model step by step:
- Node packages — scaffold and
implement Node pkgs with
nros::node!. - Bringup packages — declare your topology in a Bringup pkg.
- Images — declare the
[image.*]row that becomes a runnable program. - C / C++ multi-node workspaces — use the same project shape with CMake.
For C Node pkgs alongside C++ and Rust ones, see Mixed-language workspace.
For the full API reference covering all three roles, see Role reference.