How to Use RTIC (Real-Time Interrupt-driven Concurrency) in Rust

RTIC is a framework that transforms Rust into a real-time operating system by using the compiler to enforce safe, priority-based scheduling of tasks across interrupts and a main loop.

When interrupts need a traffic cop

You are building a drone controller. The motor loop must fire every millisecond to keep the craft stable. A telemetry task sends flight data over UART. A debug task prints logs when a button is pressed. If the telemetry task hogs the CPU, the motors jitter. If the debug task interrupts the motor loop, the drone crashes. You need a scheduler that guarantees the motor loop runs on time, every time, without you writing a single context switch by hand.

RTIC stands for Real-Time Interrupt-driven Concurrency. It turns your microcontroller code into a deterministic scheduler. Instead of writing a loop { ... } and hoping interrupts don't mess you up, you declare tasks and priorities. The compiler generates the interrupt vectors and the scheduling logic. It enforces that high-priority tasks can preempt low-priority ones, and it proves that shared data access is race-free based on those priorities.

The compiler becomes your scheduler, and the borrow checker becomes your race detector.

The priority graph

RTIC models your application as a graph of tasks and resources. Each task has a priority. Each resource has a list of tasks that can access it. The compiler builds a priority graph and checks it for cycles and conflicts.

When a task accesses a shared resource, it calls .lock(). This disables interrupts for that task's priority and any lower priorities. It creates a critical section. The compiler ensures that only one task can hold the lock at a time. If two tasks try to access the same resource in a way that violates the priority ordering, the code won't compile.

RTIC also implements the priority ceiling protocol automatically. If a low-priority task holds a resource that a high-priority task needs, the low-priority task effectively runs at the high priority while holding the lock. This prevents priority inversion. A medium-priority task can never starve a high-priority task by holding a shared resource. The protocol is baked into the generated code. You don't configure it. The compiler enforces it.

Trust the priority graph. If the compiler accepts your code, the scheduler is deterministic and race-free.

Minimal example

Here is a minimal RTIC application for a Cortex-M device. It defines a shared counter and two tasks: a timer interrupt and an idle loop.

#![no_std]
#![no_main]

use panic_halt as _;
use rtic::app;

/// WHY: The macro generates the interrupt vector table and scheduler.
/// It replaces the standard main function and entry point.
#[rtic::app(device = pac, dispatchers = [TIM1])]
mod app {
    use crate::pac;

    /// WHY: Shared resources are accessible by multiple tasks.
    /// The compiler enforces priority-based locking here.
    #[shared]
    struct Shared {
        counter: u32,
    }

    /// WHY: Local resources belong to exactly one task.
    /// No locking overhead is needed for these.
    #[local]
    struct Local {
        led_state: bool,
    }

    /// WHY: Initialization runs once before the scheduler starts.
    #[init]
    fn init(cx: init::Context) {
        // WHY: Initialize resources with default values.
        cx.shared.counter = 0;
        cx.local.led_state = false;
    }

    /// WHY: This task binds to the TIM1 interrupt.
    /// It has higher priority than idle.
    #[task(binds = TIM1, shared = [counter])]
    fn timer_tick(cx: timer_tick::Context) {
        // WHY: lock() disables interrupts at this priority and below.
        // This guarantees atomic access to the shared counter.
        let mut counter = cx.shared.counter;
        counter.lock(|c| *c += 1);
    }

    /// WHY: The idle task runs when no interrupts are pending.
    /// It acts as the main loop.
    #[task(local = [led_state], shared = [counter])]
    fn idle(cx: idle::Context) {
        let led = cx.local.led_state;
        let counter = cx.shared.counter;

        // WHY: Read the counter safely.
        // The compiler knows idle has lower priority than timer_tick,
        // so it allows this access without a full critical section
        // if the access is read-only and safe.
        let count = counter.lock(|c| *c);

        if count % 100 == 0 {
            *led = !*led;
            // WHY: Toggle GPIO hardware here.
        }
    }
}

Trust the macro. It generates the vector table and the scheduler state machine so you don't have to.

How the scheduler works

When you compile, the #[rtic::app] macro expands into a massive amount of code. It creates the interrupt vector table. It maps timer_tick to the TIM1 interrupt handler. It generates a state machine for the scheduler.

At runtime, when TIM1 fires, the hardware jumps to the vector. The RTIC runtime checks the priority. It saves the context of idle, runs timer_tick, and restores idle. The .lock() call on counter disables interrupts for timer_tick and any lower priority tasks. This prevents idle from reading a half-updated counter.

The compiler checks the priority graph. If timer_tick tries to access a resource that idle is holding in a way that violates the priority ceiling, the code won't compile. The graph must be acyclic. Resources cannot form dependency cycles between tasks.

The priority graph is the law. If the compiler can't prove the graph is acyclic and safe, your code won't build.

Realistic example: Sensor and display

Real applications often share data between interrupts and peripherals. Here is a scenario with a sensor reading via I2C and a display update via SPI.

#![no_std]
#![no_main]

use panic_halt as _;
use rtic::app;

#[rtic::app(device = pac, dispatchers = [I2C1_EV])]
mod app {
    use crate::pac;

    #[shared]
    struct Shared {
        /// WHY: Buffer for sensor data shared between I2C task and display task.
        temp_buffer: [u8; 4],
        /// WHY: Mutex-like protection for the display peripheral.
        display: DisplayDriver,
    }

    #[local]
    struct Local {
        /// WHY: I2C peripheral is only used by the sensor task.
        i2c: I2cDriver,
    }

    #[init]
    fn init(cx: init::Context) {
        // WHY: Initialize peripherals and resources.
        cx.shared.temp_buffer = [0; 4];
        cx.shared.display = DisplayDriver::new(/* ... */);
        cx.local.i2c = I2cDriver::new(/* ... */);
    }

    /// WHY: High priority task. Reads sensor immediately when ready.
    #[task(binds = I2C1_EV, local = [i2c], shared = [temp_buffer])]
    fn sensor_ready(cx: sensor_ready::Context) {
        let i2c = cx.local.i2c;
        let buffer = cx.shared.temp_buffer;

        // WHY: Read data into the shared buffer.
        // The lock ensures the display task can't read garbage data.
        buffer.lock(|buf| {
            i2c.read(buf);
        });
    }

    /// WHY: Medium priority. Updates display periodically.
    #[task(binds = TIM2, shared = [temp_buffer, display])]
    fn update_display(cx: update_display::Context) {
        let buffer = cx.shared.temp_buffer;
        let display = cx.shared.display;

        // WHY: Lock both resources.
        // RTIC handles the lock ordering based on priority to prevent deadlocks.
        buffer.lock(|buf| {
            display.lock(|disp| {
                disp.write(buf);
            });
        });
    }
}

Deadlocks are impossible by design, but starvation is still on you. Keep your critical sections short.

Scheduling with monotonics

RTIC isn't just about interrupts. You can schedule tasks based on time using monotonics. A monotonic is a timer peripheral that counts up without wrapping. You declare a monotonic in the app macro.

#[rtic::app(device = pac, dispatchers = [TIM1])]
mod app {
    use rtic::monotonics::Monotonic;

    #[monotonic(binds = TIM1, default = true)]
    struct Timer;

    #[task(binds = TIM1)]
    fn timer_tick(_cx: timer_tick::Context) {
        // WHY: This task just updates the monotonic clock.
        // The RTIC runtime handles the bookkeeping.
    }

    #[task(shared = [counter])]
    fn periodic_work(cx: periodic_work::Context) {
        // WHY: Schedule this task to run again in 10ms.
        cx.spawn_after(Duration::from_millis(10), periodic_work).unwrap();
    }
}

The spawn_after method schedules a task to run after a delay. The RTIC runtime uses the monotonic to track when the task should fire. When the time arrives, the runtime triggers the task as if an interrupt fired. This lets you write periodic tasks without configuring hardware timers for every interval.

Convention: Mark one monotonic as default = true. This makes it the default for all spawn_after calls. You don't have to specify the monotonic type every time.

Convention: List all unused interrupts that might fire in the dispatchers list. If an interrupt fires that RTIC doesn't know about, it can break the scheduler. dispatchers tells RTIC to generate a safe handler for those interrupts.

Never block in a high-priority interrupt. The scheduler is only as responsive as your fastest task.

Pitfalls and compiler errors

RTIC catches many errors at compile time, but you can still trip up.

If you try to move data out of a .lock() closure, the compiler rejects you with E0507 (cannot move out of borrowed content). The resource must stay in the RTIC resource manager. Copy or clone the data if you need it elsewhere.

If you try to access a shared resource without declaring it in the shared = [...] list, the compiler rejects you with E0609 (no field) or a macro error. You must explicitly declare dependencies.

If you declare a resource in shared but only one task uses it, the compiler warns you. Move it to local to remove the locking overhead.

If you create a cycle in the priority graph, the compiler rejects the code. RTIC requires an acyclic graph. You cannot have Task A depend on Resource X, Task B depend on Resource Y, and both tasks access both resources in a way that creates a cycle.

If you forget to list an interrupt in dispatchers and that interrupt fires, the system may panic or behave unpredictably. RTIC relies on the vector table being complete.

Treat the priority graph as a contract. If the compiler accepts it, the scheduler is safe. If it rejects it, fix the graph.

Decision: RTIC vs alternatives

Use RTIC when you need deterministic scheduling on a Cortex-M microcontroller and want the compiler to enforce race freedom across interrupts. Use RTIC when your application has distinct tasks with different timing requirements, like a fast motor control loop and a slow telemetry sender. Use RTIC when you want to avoid the overhead of a full RTOS but need more structure than bare-metal interrupts.

Use manual cortex-m-rt interrupt handlers when your system is tiny, has no shared state between interrupts, and you want zero framework overhead. Use manual handlers when you only have one or two interrupts and the logic is simple.

Use a full RTOS like FreeRTOS or Zephyr when you need dynamic task creation, complex IPC mechanisms, or support for non-Cortex-M architectures. Use an RTOS when your application requires features like message queues, semaphores, or dynamic memory allocation for tasks.

Use bare-metal polling in a single loop when your device has only one job and no external events that require immediate response. Use polling when power consumption is critical and you can sleep the CPU between iterations.

Pick the tool that matches your timing constraints. RTIC gives you determinism for free; pay for it with compile-time complexity.

Where to go next