-
Notifications
You must be signed in to change notification settings - Fork 8
User Guide Syntax
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.
The #[<distro>::app(...)] attribute is placed on the module that contains your application.
#[<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 is1). -
dispatchers = [...]— single-core list of interrupt dispatchers for software tasks. -
dispatchers = [[...], [...]]— per-core dispatcher lists for software tasks on multicore targets.
#[shared]
struct SharedResources {
alarm: Alarm0,
led: LedOutPin,
}Fields of SharedResources are accessed inside tasks via self.shared().name and locked with .lock(|resource| { ... }).
#[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].
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.
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]
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)].
#[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 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. -
1and above — increasing urgency. A task at priorityNpreempts any task at a priority lower thanN.
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
0is the highest urgency. The distribution maps a logical priorityNto the corresponding hardware value (applyingNVIC_PRIO_BITSshifting), 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 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 to1.
Each software task owns an input queue that buffers the values passed to spawn/spawn_from until the task's dispatcher runs.
- Increasing
capacitylets 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,
spawnreturnsErr(input).
-
MySwTask::spawn(value)— spawn from the same core. -
MySwTask::spawn_from(core, value)— spawn from another core, when allowed.
Spawning from #[init] always returns Err(input). Use #[post_init] to spawn initial tasks.
#[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
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.
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.
#[<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 is1. -
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.
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.
#[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 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::spawn_from(core_token, input).
// From a task on core 1, spawn a task on core 0:
Core0Task::spawn_from(Self::current_core(), 42)?;Self::current_core() returns a zero-sized token (__rticx__internal__Core1) that proves at compile time which core is calling spawn_from. The token is type-checked against the task's spawn_by constraint — attempting to call spawn_from from the wrong core is a compile error.
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 tasks provide async fn / .await support in RTICX. Each async task has a future polled by an executor loop — which is a hardware-task dispatcher shared by all tasks at the same priority or (core, priority) pair in multicore case.
Async tasks require the async distribution feature and the rticx-async crate in your dependencies.
rticx-asyncuses atomics and critical sections. On targets without native atomics the distribution enables theatomic-critical-sectionfallback, and multicore targets must use a spinlock-based critical-section backend (e.g. the RP2040 hardware spinlocks). See Multicore Architecture & Design Decisions.
#[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) { ... }
}
Like software tasks, async tasks own an input queue whose size is controlled by capacity = N
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.
let _ = Worker::spawn(input); // same core → Result<(), Input>
let _ = Worker::spawn_from(core_token, 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)Spawning from #[init] always returns Err(input). Use #[post_init] to spawn initial 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 != coreis disallowed for priority-0 tasks.
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.
The following passes are available but may add their own attributes. Detailed syntax for each will be added as the documentation expands:
-
rticx-deadline-pass— convertsdeadline = Dattributes into RTICX priorities. -
rticx-auto-assign— automatically assignscore = Nbased on shared resource usage. -
rticx-async-pass— adds#[async_task]with async/await, channels, and executor dispatching (see Async software tasks above).
See the Supported Distributions page to know which passes are enabled by each distribution.
- Supported Distributions — feature flags per distribution.
- Distributor Guide — how these attributes are processed internally.