RTIC Integration
RTIC (Real-Time Interrupt-driven Concurrency) is a
concurrency framework for ARM Cortex-M that compiles tasks directly to
hardware interrupt handlers — no RTOS kernel, no task control blocks, no
software scheduler. nano-ros runs on top of RTIC by letting the framework
own fn main, the scheduler, and the dispatchers, while nano-ros
contributes one spin task and (optionally) one callback-dispatch task.
This chapter is the user-facing tutorial for that integration. For the
underlying design — why per-Node dispatch strategies, why tags instead of
closures, why no AsyncNode trait yet — see the sibling internals page
Dispatch Strategy.
When to reach for nros::main!() instead of Pattern A
The Pattern A escape hatch is hand-written #[rtic::app], manual
Executor::open inside #[init], manual spin_once(0) polling task. It looks
like this:
#![allow(unused)]
fn main() {
// Pattern A, written by hand
#[rtic::app(device = mps2_an385_pac, dispatchers = [UARTRX0, UARTTX0])]
mod app {
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
let syst = nros_board_mps2_an385::init_hardware(&config, cx.device, cx.core);
nros_rmw_zenoh::register().expect("Failed to register RMW backend");
let mut executor = Executor::open(&exec_config).unwrap();
let mut node = executor.create_node("talker").unwrap();
let publisher = node.create_publisher::<Int32>("/chatter").unwrap();
net_poll::spawn().unwrap();
publish::spawn().unwrap();
(Shared {}, Local { executor, publisher })
}
#[task(local = [executor], priority = 1)]
async fn net_poll(cx: net_poll::Context) {
loop {
cx.local.executor.spin_once(core::time::Duration::from_millis(0));
Mono::delay(10.millis()).await;
}
}
}
}
That pattern works, but it’s ~90 lines of glue per binary, and it puts the burden of getting RTIC + nros + the dispatcher list right on every example author. The macro collapses it to one line:
#![allow(unused)]
fn main() {
// File: examples/mps2-an385-baremetal/rust/talker-rtic/src/main.rs
#![no_std]
#![no_main]
use panic_semihosting as _;
nros::main!();
}
The nros::main!() proc-macro reads the image’s board out of
system.toml — [image.<id>] board = "rtic-mps2-an385" — resolves that
board to the rtic framework, and emits a full #[rtic::app] module,
including #[init], __nros_spin, and (if any deployed Node declares
DispatchStrategy::Deferred) __nros_dispatch.
The framework is a property of the board, never something you spell in
a manifest: rtic-mps2-an385 resolves to rtic,
zephyr to Zephyr, esp32-qemu and esp32-c3-baremetal to the ESP32
shape, and every other board — native, FreeRTOS, NuttX, ThreadX,
non-RTIC bare-metal — to the default owned-spin fn main. An out-of-tree
board crate that is not in that table declares its own framework in
[package.metadata.nros.board]. So moving a program between RTIC and a
plain spin loop is a board change, not a code change — no main.rs, no
node source, and no build command moves with it.
One caveat for a single package: it is its own entry, so the board
crate, its rtic feature, the RMW backend and the panic handler come from a
generated <pkg>_nros_selection package the in-tree RTIC examples depend on
(nros sync writes it for the image’s board;
issue 1305).
rtic and the PAC stay in the package’s own [dependencies], because the
#[rtic::app] expansion names rtic:: and device = mps2_an385_pac from
that crate; they belong to the RTIC program, not to the board, and are the
only image-shaped rows such a package keeps. A workspace’s generated entry has no such seam.
Pick nros::main!() whenever:
- You want a one-line
main.rsand don’t need custom RTIC tasks. - Your Node logic is portable across boards (the Node pkg is framework-agnostic; only the image’s board picks RTIC).
- You’re happy with the default dispatcher list from the board crate.
Keep Pattern A when:
- You need fine-grained control of dispatcher priorities, monotonic
setup, or hand-tuned
#[shared]state. - You’re shipping a one-off bring-up binary and don’t want a
system.tomland a Node-pkg split at all.
Both paths stay supported. The escape hatch is the “I want full
control” path; nros::main!() is the ergonomic path on top.
Where the pieces live
Two shapes, and the RTIC choice is made in the same place in both: one
[image.<id>] board in system.toml naming an RTIC board.
A single package is what every in-tree RTIC example is — see
examples/mps2-an385-baremetal/rust/talker-rtic:
talker-rtic/
├── package.xml
├── Cargo.toml # dependencies, incl. the board crate's `rtic` feature
├── system.toml # the board, the RMW, the components
└── src/
├── lib.rs # impl Component for Talker + nros::node!(Talker)
└── main.rs # nros::main!();
A workspace is node packages plus a bringup (the 3-pkg-role
taxonomy, per
docs/design/0024-multi-node-workspace-layout.md §11). There is no root
build file and no Entry package to write — the entry is generated per
[image.*] under build/:
my_rtic_robot/
├── .colcon_workspace # marks the root; no root build file
└── src/
├── talker_pkg/ # Node pkg — board-agnostic
│ ├── package.xml
│ ├── Cargo.toml
│ └── src/lib.rs # impl Component for Talker + nros::node!(Talker)
└── demo_bringup/ # Bringup pkg — no code
├── system.toml
└── launch/
- Node pkg — declares what the node does (publishers,
subscriptions, services, actions). No
main, no#[rtic::app], no board choice. Builds asrlib + staticliband gets linked into the generated entry. - Bringup pkg — holds
system.tomland the launch files. This is where the board, and therefore RTIC, is chosen. - Entry — generated, not authored. It is the
#[rtic::app]shell, derived from the image; you never see it unless you go looking underbuild/.
The split exists so the same talker_pkg/ can be deployed under RTIC
on bare-metal Cortex-M, under FreeRTOS on QEMU, and under POSIX on a Linux host
without any per-target Node-pkg fork.
The system.toml that makes it RTIC
[system]
name = "my_rtic_robot"
rmw = "zenoh"
domain_id = 0
# `dispatch` — callbacks hand off to a ring rather than running inline;
# read by `nros check`'s framework × dispatch lint.
[[component]]
pkg = "talker_pkg"
class = "talker_pkg::Talker"
name = "talker"
dispatch = "inline"
[image.rtic]
board = "rtic-mps2-an385"
locator = "tcp/192.168.1.10:7447"
board is the only line that says RTIC. Point it at mps2-an385-freertos
and the same components build as a FreeRTOS image — in a workspace, with
nothing else to change; in a single package too, whose board comes from its
generated selection package (issue 1305 above).
A minimal Node pkg — pub-only Talker
A pub-only Node declares DispatchStrategy::Inline (the default) — it
publishes from its own RTIC task and never enters on_callback.
#![allow(unused)]
fn main() {
// File: src/talker_pkg/src/lib.rs
#![no_std]
use core::time::Duration;
use nros::prelude::*;
use std_msgs::msg::Int32;
pub struct Talker;
pub struct TalkerState {
publisher: Publisher<Int32>,
counter: i32,
}
impl Component for Talker {
const NAME: &'static str = "talker";
// Inline is the default; spelled out here for clarity.
const DISPATCH: DispatchStrategy = DispatchStrategy::Inline;
fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
ctx.create_publisher::<Int32>("/chatter")?;
Ok(())
}
}
impl ExecutableNode for Talker {
type State = TalkerState;
fn init() -> Self::State {
TalkerState { publisher: Publisher::placeholder(), counter: 0 }
}
fn tick(state: &mut Self::State, _ctx: &mut TickCtx<'_>) {
state.counter = state.counter.wrapping_add(1);
let _ = state.publisher.publish(&Int32 { data: state.counter });
}
fn tick_period(_state: &Self::State) -> Option<Duration> {
Some(Duration::from_millis(1000))
}
}
nros::node!(Talker);
}
# File: src/talker_pkg/Cargo.toml
[package]
name = "talker_pkg"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib", "staticlib"]
[dependencies]
nros = { version = "*", default-features = false }
std_msgs = { path = "generated/std_msgs", default-features = false }
Dependencies and nothing else. The class name, the node name and the
dispatch strategy are the [[component]] row in system.toml, and there
is no root manifest to inherit workspace = true from.
The entry
In a workspace there is nothing to write — the entry is generated from the image. In a single-package example it is one file:
#![allow(unused)]
fn main() {
// File: src/main.rs
#![no_std]
#![no_main]
use panic_semihosting as _;
nros::main!();
}
Either way the proc-macro reads the board from system.toml, resolves it
to the rtic framework, and expands into a
#[rtic::app(device = mps2_an385_pac, dispatchers = [UARTRX0, UARTTX0])]
module with auto-generated #[init], __nros_spin, and per-Node state
slots. A single-package leaf gets the board crate, with its rtic feature,
from its generated selection package; it names only the PAC and rtic
itself, because the #[rtic::app] expansion refers to both from the leaf’s
own crate. A workspace’s generated entry pulls all of it in for you.
DispatchStrategy::Inline vs Deferred
A Node pkg declares DispatchStrategy via the Node::DISPATCH const.
Two variants matter today; FromIsr is reserved as a design slot (see
DispatchStrategy::FromIsr).
Inline — pub-only or tick-only Nodes
#![allow(unused)]
fn main() {
impl Component for Talker {
const NAME: &'static str = "talker";
const DISPATCH: DispatchStrategy = DispatchStrategy::Inline;
// ...
}
}
Inline means: callbacks (if any) fire from the executor’s spin loop —
the same RTIC task that polls the network transport. On RTIC that’s
__nros_spin running at priority = 1.
Pick Inline when:
- The Node has no subscriptions, no service handlers, no action
handlers. Its only output is
Publisher::publishcalls fromtickor from a custom RTIC task you spawn yourself. - The Node has subscriptions, but the per-message work is so cheap (microseconds, no locks, no shared-state touches) that running it inline with the spin loop is acceptable.
The Talker above is the canonical Inline shape: it publishes from
tick at 1 Hz, never receives anything, and never blocks the spin
loop.
Deferred — callback-driven Nodes
#![allow(unused)]
fn main() {
impl Component for Listener {
const NAME: &'static str = "listener";
const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
// ...
}
}
Deferred means: when a callback arrives, the spin loop enqueues a callback
token plus context into a heapless::spsc::Queue and returns immediately.
A separate __nros_dispatch RTIC task (typically priority = 2, one
above spin) drains the queue and calls ExecutableNode::on_callback from
its own task context.
Pick Deferred when:
- The Node’s
on_callbackhandler must hold an RTIC lock on a#[shared]resource — running it from the spin task would force every other dispatcher at the spin’s priority to wait on the lock. - The handler does non-trivial work (parses, integrates, posts to a hardware peripheral) and you want it scheduled independently from network polling.
- The Node sits at a different priority tier than the spin task.
The tag-based registration API for Deferred Nodes
Deferred Nodes can’t use the closure form ctx.create_subscription("/chatter", |msg| { ... }) —
the closure captures state by value, and a no-alloc framework
runtime has no place to store an unknown closure type. Instead, you
register with _static and dispatch via tag match in on_callback:
#![allow(unused)]
fn main() {
// File: src/listener_pkg/src/lib.rs
#![no_std]
use nros::prelude::*;
use std_msgs::msg::Int32;
pub struct Listener;
pub struct ListenerState {
sub_chatter: SubscriptionTag,
}
impl Component for Listener {
const NAME: &'static str = "listener";
const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
let _tag = ctx.create_subscription_static::<Int32>("/chatter")?;
Ok(())
}
}
impl ExecutableNode for Listener {
type State = ListenerState;
fn init() -> Self::State {
// The macro-emitted glue overwrites the placeholder with the
// real tag at register time.
ListenerState { sub_chatter: SubscriptionTag::placeholder() }
}
fn on_callback(
state: &mut Self::State,
cb: Callback<'_>,
ctx: &mut CallbackCtx<'_>,
) {
if state.sub_chatter == cb {
let msg: Int32 = ctx.downcast().unwrap();
defmt::info!("Received: {}", msg.data);
}
}
}
nros::node!(Listener);
}
The same shape applies to services (ServiceTag /
create_service_static) and actions (ActionTag /
create_action_static). The macro lint (216.A.6) rejects mixing
closure registration into a Deferred Node — the error spans the
offending call and suggests switching to the _static form.
The custom-task escape — nros::main!(custom_tasks = ...)
This form is implemented —
nros::main!parsescustom_tasksalongside the RTIC routing branch.
Real RTIC applications don’t only have nano-ros tasks. You want a
dedicated my_adc task to poll an ADC into a #[shared] buffer, or a
my_ui task to drive an OLED. The custom_tasks = [...] form of
nros::main!() folds your extra task fns into the generated
#[rtic::app] module:
#![allow(unused)]
fn main() {
// File: src/main.rs (with custom tasks)
#![no_std]
#![no_main]
use panic_semihosting as _;
nros::main!(custom_tasks = [my_adc, my_ui]);
#[rtic_task(priority = 3, shared = [adc_data])]
async fn my_adc(mut ctx: my_adc::Context) {
loop {
let sample = read_adc();
ctx.shared.adc_data.lock(|d| *d = sample);
Mono::delay(10.millis()).await;
}
}
#[rtic_task(priority = 2)]
async fn my_ui(_ctx: my_ui::Context) {
loop {
draw_screen();
Mono::delay(33.millis()).await;
}
}
}
The proc-macro extracts the user task tokens verbatim, splices them
into the generated mod __nros_app, and adds their dispatchers to the
RTIC dispatchers = [...] list. Signatures + attributes + priorities
are preserved.
Custom tasks can interact with nano-ros via the #[shared] resources
the macro exposes — typically executor (the spin executor) for raw
publisher access, or a Node’s state slot for direct handler calls.
Specifics will be locked when 216.B.4 lands.
DispatchStrategy::FromIsr — not yet
The third variant of DispatchStrategy is reserved for callbacks that
fire directly from an ISR handler (e.g. a timer pulse triggering a
publish without a scheduler hop). This is a design slot only;
the implementation is deferred.
Landing it requires:
- A reentrancy audit of the dispatch path.
- A lock-free SPSC variant tolerant of ISR-priority producers.
- A per-Node
#[isr_safe]proof contract.
Until that work lands, nros check rejects
DispatchStrategy::FromIsr deployments with a clear diagnostic.
See also
- Dispatch Strategy (internals) — the trichotomy and the per-Node-vs-per-callback rationale.
- Embassy Integration — the async sibling of this chapter.
- Role reference — the 3-pkg-role taxonomy in full.
- Scheduling Models — the RTIC scheduling model in real-time-systems terms.