Skip to content

User Guide Syntax

Zakaria Madaoui edited this page Aug 18, 2026 · 19 revisions

Syntax Reference

This page describes the RTICX attributes currently supported by the core pass (rticx-core) and the software-tasks pass (rticx-sw-pass). Other officially supported passes may add their own attributes; those will be documented as they are added.

Examples

If you are a learn by example person, check out these repositories to see RTICX in action across different architectures and setups

Migrating from RTIC v2

Porting an existing RTIC v2 (rtic 2.x) application to RTICX? There is a comprehensive, step-by-step migration guide in the repository:

RTICv2 to RTICX Migration Guide

The guide doubles as an AI-assistant reference: point your LLM at it (or load it as the rticv2-to-rticx-migration skill) to have the bulk of the port done for you.

Application-level attributes: #[app(...)]

The #[<distro>::app(...)] attribute is placed on the module that contains your application.

Common arguments

#[<distro>::app(device = rp2040_hal::pac, dispatchers = [DMA_IRQ_0])]
mod my_app { ... }
  • device = path: path to the PAC (Peripheral Access Crate) for the target.
  • cores = N: number of cores (default is 1).
  • dispatchers = [...]: single-core list of interrupt dispatchers for software tasks.
  • dispatchers = [[...], [...]]: per-core dispatcher lists for software tasks on multicore targets.

Shared resources: #[shared]

#[shared]
struct SharedResources {
    alarm: Alarm0,
    led: LedOutPin,
}

Fields of SharedResources are accessed inside tasks via self.shared().name and locked with .lock(|resource| { ... }).

Init: #[init]

#[init]
fn system_init() -> (SharedResources, TaskInits) {
    // setup code
    (
        SharedResources { alarm, led },
        TaskInits { blink: Blinker::new(led_pin), worker: Worker },
    )
}

The init function runs once at startup and returns a tuple of the initial shared resources and a TaskInits struct holding one value per user task (see TaskInits). On multicore targets, use #[init(core = N)]; the TaskInits type is then named TaskInitsCoreN and holds the tasks of that core only. Applications without #[shared] return TaskInits only from #[init].

TaskInits

Every task you define (#[task], #[sw_task], #[async_task] and #[idle]) must be constructed by you and returned from #[init] inside the generated TaskInits struct

#[init]
fn system_init() -> (Shared, TaskInits) {
    // ...
    (
        Shared { counter: 0 },
        TaskInits {
            tick: Tick,                                  // unit struct constructed inline
            blinker: Blinker::new((led, alarm)),         // helper function you write yourself
            my_idle_task: MyIdleTask { count: 0 },       // struct literal
        },
    )
}

Construction helpers are plain inherent methods on the task structs, so you are free to give them any shape you like:

impl Blinker {
    pub fn new(led: LedPin, alarm: Alarm0) -> Self {
        Self { is_high: false, led, alarm }
    }
}

Task construction happens during #[init], before tasks can be spawned; use #[post_init] for any startup spawns.

init = generated (reduce TaskInits boilerplate)

A task marked with init = generated is excluded from TaskInits and is constructed by the framework itself at boot as a unit literal:

#[task(binds = TIMER_IRQ_0, priority = 1, init = generated)]
struct Tick;

impl RticTask for Tick {
    fn exec(&mut self) { /* ... */ }
}

Idle: #[idle]

#[idle]
struct MyIdleTask;
impl RticIdleTask for MyIdleTask {
    fn exec(&mut self) -> ! {
        loop { /* low-power or background work */ }
    }
}

Custom idle tasks are constructed through TaskInits like any other task. On multicore targets, use #[idle(core = N)].

Hardware tasks: #[task(...)]

#[task(binds = TIMER_IRQ_0, priority = 3, shared = [alarm, led])]
struct MyTask;
impl RticTask for MyTask {
    fn exec(&mut self) { /* handler code */ }
}
  • binds = IRQ: interrupt line that triggers this task.
  • priority = N: task priority. Higher values preempt lower values.
  • shared = [...]: list of shared resource fields this task accesses.
  • core = N: on multicore targets, the core on which the interrupt lives.
  • init = generated: see TaskInits.

Task priorities

Task priorities are logical values and always increase with urgency:

  • 0: the idle (disabled) priority. Reserved for the #[idle] task and priority-0 async tasks; interrupt-driven tasks cannot use it.
  • 1 and above: increasing urgency. A task at priority N preempts any task at a priority lower than N.

If you omit priority = N, the task is assigned priority 1.

These logical values are not the raw hardware values. Each distribution converts them to the target's hardware encoding when it programs the interrupt controller (see Writing Distributions):

  • Cortex-M / ARM: hardware priority numbering is reversed: hardware value 0 is the highest urgency. The distribution maps a logical priority N to the corresponding hardware value (applying NVIC_PRIO_BITS shifting), so a larger logical number still means higher urgency.
  • RISC-V: the hardware priority matches the logical value directly: a larger number is higher urgency, exactly as the user specifies.

The valid range of priorities is target-specific (e.g. bounded by the number of NVIC priority bits on Cortex-M, or the CLIC/SLIC threshold width on RISC-V).

Software tasks: #[sw_task(...)]

Software tasks are provided by the rticx-sw-pass compilation pass. They are triggered by other tasks via message queues instead of direct interrupts.

#[sw_task(priority = 2, shared = [led], capacity = 4, spawn_by = 0, core = 0)]
struct MySwTask;
impl RticSwTask for MySwTask {
    type SpawnInput = u16;
    fn exec(&mut self, input: u16) { /* handler code */ }
}
  • priority = N: dispatcher hardware task priority.
  • shared = [...]: shared resources this task accesses.
  • core = N: the core on which this task executes.
  • spawn_by = N: the only core allowed to spawn this task. If omitted, any core may spawn it.
  • capacity = N: size of the task's input queue: the number of pending spawns it can hold. Defaults to 1.

Input queue capacity

Each software task owns an input queue that buffers the values passed to spawn/cross_spawn until the task's dispatcher runs.

  • Increasing capacity lets the task be spawned several times back-to-back, e.g. from a higher-priority task or tasks (several producers), without losing inputs. Buffered inputs are processed in FIFO order, one per dispatch.
  • When the queue is full, spawn returns Err(input).

Spawning software tasks

let _ = MySwTask::spawn(value);

Spawning from #[init] always returns Err(input). Use #[post_init], the exec() function in #[idle] or any other #[*task] to spawn tasks.

Software tasks are deferred work, not function calls

A software task is not a function call: spawn() does not run exec synchronously at the call site. It enqueues a message and returns as soon as the value is accepted (or Err(input) if the queue is full). The task body runs later, on the dispatcher interrupt of the task's priority level. The only exception is if the spawned task's priority is higher than the context it was spawned from.

Execution is therefore not guaranteed to be immediate:

  • Priority determines how soon the task runs. A spawned task is serviced by the dispatcher of its own priority level, so it only runs when the CPU is available at that priority: higher-priority tasks and the task that spawned it preempt it; lower-priority activity does not delay it. The dispatcher of a higher-priority group runs whenever it is pending, which is why spawn from a high-priority context can delay a lower-priority software task indefinitely until that context finishes.
  • The dispatcher pipeline is FIFO. All software tasks belonging to the same dispatcher (same priority level, same core) form a queue and execute in spawn order, one at a time, each run to completion. A task that runs for a long time delays the dispatcher, and thereby starves the other software tasks behind it in the queue (and delays any of their pending spawns).
  • A higher-priority dispatcher preempts a lower-priority one mid-task; the preempted task resumes only when the higher-priority pipeline drains.

Consequences for design:

  • Use software tasks for deferred work: offload computation from an interrupt context, or delay work to a moment where priority/locking constraints are relaxed.
  • Because any task in a dispatcher pipeline can starve the others, exec should be short and bounded. Long-running or potentially unbounded processing belongs in the idle task, in a dedicated priority level, or in an async task.
  • Choose priority deliberately: it buys preemption against lower-priority work, but everything below that priority takes the cost, and tasks at the same priority must share their dispatcher.

Post Init: #[post_init]

#[post_init]
fn spawn_initial_tasks() {
    let _ = MySwTask::spawn(1);
}

The post_init (optional) function runs once at startup after the system initialization finishes. On multicore targets, use #[post_init(core = N)]. use this function to spawn any software or asynchronous tasks

Resource locking

Inside a task, shared resources are accessed through the shared proxy and locked to obtain a mutable reference:

self.shared().led.lock(|led| {
    let _ = led.set_high();
});

The lock implementation is target-specific and generated by the distribution backend.

Multicore

RTICX supports single-binary multicore: all cores share the same address space and execute from a single firmware image. Tasks are assigned to cores, and cross-core communication uses software/async task spawning.

Enabling multicore

#[<distro>::app(device = rp2040_hal::pac, cores = 2, dispatchers = [[DMA_IRQ_0], [DMA_IRQ_1]])]
mod my_app { ... }
  • cores = 2: declares the number of cores. Default is 1.
  • dispatchers = [[...], [...]]: per-core dispatcher lists. The outer array indexes cores; inner lists are dispatcher IRQs for that core's software/async tasks.
  • device = [path0, path1]: per-core PAC paths (when they differ). A single path is replicated across all cores.

Core assignment

Every task must specify which core it runs on:

Task type Attribute
#[init] #[init(core = 0)]
#[idle] #[idle(core = 0)]
#[task(...)] #[task(binds = IRQ, core = 0, ...)]
#[sw_task(...)] #[sw_task(priority = N, core = 0, ...)]
#[async_task(...)] #[async_task(priority = N, core = 0, ...)]

The core = N on a hardware task specifies which core the interrupt line belongs to. On software/async tasks, it specifies which core's dispatcher manages the task.

Per-core shared resources

#[shared(core = 0)]
struct SharedCore0 {
    uart_tx: UartTx,
}

#[shared(core = 1)]
struct SharedCore1 {
    dma_buf: DmaBuffer,
}

Each core gets its own shared resource group. Only tasks on that core may access those resources. SRP ceilings are computed independently per core.

Cross-core tasks

Cross-core tasks are software/async tasks where one core produces (spawns) and another core consumes (executes):

#[sw_task(priority = 2, core = 0, spawn_by = 1)]
struct Core0Task;
impl RticSwTask for Core0Task {
    type SpawnInput = u32;
    fn exec(&mut self, input: u32) { /* runs on core 0 */ }
}
  • core = 0: this task executes on core 0.
  • spawn_by = 1: only core 1 may spawn this task.

When spawn_by == core (or is omitted), the task is core-local and uses Task::spawn(input). When spawn_by != core, the task is cross-core and uses Task::cross_spawn(input).

Spawning across cores

// From a task on core 1, spawn a task on core 0:
Core0Task::cross_spawn(42)?;

Cross-core spawning is enforced at runtime: the generated cross_spawn first reads the id of the core it is actually executing on (e.g. the cpuid register on the RP2040) and rejects the spawn with Err(Some(input)) unless the caller runs on the task's spawn_by core. The check is supplied by the distribution backend (current_core_id); apps with cross-core tasks fail to compile if the distribution does not implement it.

Core-local spawn is guarded the same way against the task's own core.

Spawn API Allowed caller
Task::spawn(input) Code executing on the task's core
Task::cross_spawn(input) Code executing on the task's spawn_by core

Dispatcher constraints

A priority level (on a given core) can be used for either core-local tasks or cross-core tasks, but never both. This is because each dispatcher queue has exactly one producer and one consumer:

Task kind Producer Consumer Same core?
Core-local (spawn_by == core) Core N Core N (dispatcher) Yes
Cross-core (spawn_by != core) Core M Core N (dispatcher) No

If a priority level mixed core-local and cross-core tasks, the dispatcher queue would have multiple producers on different cores, breaking the single-producer guarantee of the SPSC queue.

For the reasoning behind these constraints (SPSC queue ordering, how cross-core interrupts are delivered, e.g. SIO FIFO on RP2040, and async waker cross-core mechanics), see the Architecture and Multicore Architecture & Design Decisions pages.

Async software tasks: #[async_task(...)]

Async tasks provide async fn / .await support in RTICX. Each async task has a future polled by an executor loop, a hardware-task dispatcher shared by all tasks at the same priority or (core, priority) pair in the multicore case.

Async tasks require the async distribution feature and the rticx-async crate in your dependencies.

rticx-async uses atomics and critical sections. On targets without native atomics the distribution enables the atomic-critical-section fallback, and multicore targets must use a spinlock-based critical-section backend (e.g. the RP2040 hardware spinlocks). See Multicore Architecture & Design Decisions.

Syntax

#[async_task(priority = 2, shared = [counter], capacity = 4)]     // core and spawn_by optional
struct Worker {
    rx: Receiver<'static, u32, 4>,
}

impl RticAsyncTask for Worker {
    type SpawnInput = ();           // input type for exec
    async fn exec(&mut self, input: Self::SpawnInput) { ... }
}

Input queue capacity

Like software tasks, async tasks own an input queue whose size is controlled by capacity = N

Channels

Task-to-task communication uses channels from rticx-async:

use rticx_async::{channel::{Receiver, Sender}, make_channel};

let (tx, rx) = make_channel!(u32, 4); 

The make_channel! macro creates a channel backed by static memory, returning (Sender<'static, T, N>, Receiver<'static, T, N>). Each call site can only execute once (a run-time guard panics on repeated calls). Channels must be created and split in #[init] and passed into tasks via TaskInits.

Spawning

let _ = Worker::spawn(input);                       // same core → Result<(), Input>
let _ = Worker::cross_spawn(input);                 // cross-core (spawn_by)
// Ok(()) on success, Err(input) if the task's input queue is full
// Err(input) if called from #[init] (tasks not yet initialized)
// cross_spawn also returns Err(Some(input)) if not called from the spawn_by core

Spawning from #[init] always returns Err(input). Use #[post_init] to spawn initial tasks.

Priority 0 async tasks

Setting priority = 0 assigns the task to the idle executor: a framework-generated #[idle] task that busy-polls all priority-0 futures. Priority-0 tasks do not require a dispatcher IRQ.

#[async_task(priority = 0)]
struct Background;
impl RticAsyncTask for Background {
    type SpawnInput = ();
    async fn exec(&mut self, _input: ()) {
        loop {
            do_background_work().await;
        }
    }
}
  • No dispatcher needed: priority-0 tasks are polled by the idle loop, not by an interrupt-driven dispatcher.
  • No custom #[idle]: defining priority-0 tasks and a custom #[idle] simultaneously is a compile error. Use #[post_init] to spawn initial tasks instead.
  • No cross-core priority 0: spawn_by != core is disallowed for priority-0 tasks.

Dispatcher requirements

Every async priority group needs a dispatcher IRQ listed in dispatchers = [...] (one per priority level). Dispatchers are hardware tasks bound to the listed IRQs; they poll all tasks in their priority group and self-pend until all futures complete.

Software tasks vs. async tasks: choosing between the two

This section compares the two kinds of software tasks supported by RTICX: lightweight #[sw_task]s (from rticx-sw-pass) and #[async_task]s (from rticx-async-pass). Both are triggered by other tasks via message queues instead of direct interrupts, and both must be spawned to run, but their execution model (and their cost) differ significantly.

Execution model

  • A #[sw_task] is a plain synchronous function: fn exec(&mut self, input). Each dispatch runs it start-to-finish on the dispatcher interrupt, exactly like a hardware task that receives its inputs through a queue.
  • An #[async_task] is an async fn exec(...). Spawning stores a future in memory; an executor polls the future on the dispatcher interrupt until it completes. The future can suspend itself at .await points (channels, timers, other futures) and resume later, spanning many dispatcher runs.

Overhead and memory footprint

Lightweight software tasks:

  • spawn is a single push into the task's SPSC input queue.
  • Memory use is one queue slot per capacity entry plus the task struct — no heap, no executor, no waker machinery.
  • Stack usage is deterministic and constant, identical to a hardware task.

Async tasks:

  • Each pending spawn materializes the task's future: the full state machine containing every local variable live across .await points.
  • Each priority group needs an executor loop with waker machinery, and rticx-async relies on atomics and critical sections (see Multicore Architecture & Design Decisions).
  • Futures and queues live in static memory; the combined footprint is typically much larger than the equivalent synchronous code.

Side effects

  • Blocking vs. suspension: a software task runs to completion and cannot wait for anything. An async task can await on channels, Mono::delay(...), and other futures, expressing multi-step workflows in a single task that would otherwise need hand-written state machines or chains of spawns.
  • CPU utilization: while an async task is suspended at an .await, lower-priority tasks get to run; a software task occupies its interrupt context until it returns.
  • Latency: software-task spawn-to-run latency is a queue push plus a dispatcher interrupt. Async adds executor polling and waker overhead per spawn, and its latency is also influenced by the other futures sharing the same priority group.
  • Determinism: software tasks behave like plain interrupts which makes them easier to reason about and to validate for hard real-time guarantees.

Feature gating

Neither feature is enabled by default: both are opt-in so each application pays only for what it uses.

Task kind Requirement
#[sw_task] distribution swtasks feature
#[async_task] distribution async feature + rticx-async in dependencies

Which one to choose

  • Hard real-time, high-performance systems with strict latency and memory budgets: use lightweight #[sw_task]s. They have a predictable cost, and a minimal footprint.
  • Development convenience and complex multi-step workflows (timers, channel-based communication, sequences of awaits): use #[async_task]s. The executor and future machinery cost overhead and memory, but let you write the control flow directly instead of decomposing it into queues and state machines.

Monotonic / Timers

RTICX does not provide its own monotonic implementation. Instead, use the existing upstream rtic-monotonics crate, which works with RTICX exactly as it does with upstream RTIC.

Setup

  1. Add rtic-monotonics to your Cargo.toml with the feature matching your timer peripheral:

    rtic-monotonics = { version = "x.x.x", features = ["cortex-m-systick"] } # for example with rticx-cortex-m distro
  2. Instantiate the monotonic at the crate root (outside the #[app] module), with the interrupt frequency in Hz:

    use rtic_monotonics::systick::prelude::*;
    systick_monotonic!(Mono, 1000);
  3. Start the timer in #[init]:

    #[init]
    fn system_init() -> (Shared, TaskInits) {
        let core = unsafe { cortex_m::Peripherals::steal() };
        Mono::start(core.SYST, 10_000_000); // 10MHz
        // ...
    }

Usage in async tasks

The monotonic is typically used from #[async_task]s via Mono::delay(...).await (requires the async feature):

#[async_task(priority = 3, init = generated)]
struct Periodic;
impl RticAsyncTask for Periodic {
    type SpawnInput = u32;
    async fn exec(&mut self, count: u32) {
        for _ in 1..=count {
            // ...
            Mono::delay(500.millis()).await;
        }
    }
}

The full rtic-monotonics API works as usual: Mono::now(), Mono::spawn_after(dur, future), Mono::spawn_at(instant, future), and the .millis()/.secs() duration constructors from the prelude.

See the async_ping_pong, async_prio0, and async_queue_depth examples in distributions/rticx-cortex-m/examples-apps/.

Other officially supported syntax extensions

RTICX is design with syntax addons support. A particular distribution may expose more syntax features than the core ones defined above. You will find examples and documentation of the syntax addons in the distributions' or compilation-passes' README.md. For instance, the following additional passes are available.

  • rticx-deadline-pass: converts deadline = D attributes into RTICX priorities.
  • rticx-auto-assign: automatically assigns core = N based on shared resource usage.

See the Supported Distributions page to know which passes are enabled by each distribution.

Next steps

Clone this wiki locally