RTIC Versus Embassy for Real Time Embedded Rust Compared
RTIC schedules interrupt driven tasks with compile time priorities; Embassy runs async tasks cooperatively. Compare the models, resource sharing, latency and when each fits.

Both frameworks solve the same problem, doing several things on one small chip, with opposite philosophies. One lets the hardware interrupt controller be the scheduler. The other turns your code into state machines on a cooperative executor. The comparison below is from the projects' documented designs, not benchmarks; measure latency on your own board before relying on a number.
The RTIC model
#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [EXTI0, EXTI1])]
mod app {
#[shared]
struct Shared {
counter: u32,
}
#[local]
struct Local {}
#[init]
fn init(_cx: init::Context) -> (Shared, Local) {
(Shared { counter: 0 }, Local {})
}
#[task(binds = TIM2, shared = [counter], priority = 2)]
fn tick(mut cx: tick::Context) {
cx.shared.counter.lock(|c| *c += 1);
}
#[task(shared = [counter], priority = 1)]
async fn report(mut cx: report::Context) {
let n = cx.shared.counter.lock(|c| *c);
let _ = n;
}
}binds = TIM2 ties tick to a hardware interrupt at priority 2. report is lower priority and the lock call raises the priority just enough to exclude tick while it reads. The framework computes the ceiling for each resource at compile time. There is no heap, no run-time scheduler and no way to write a data race on counter. This is what makes RTIC attractive for timing analysis: worst case blocking is a function of the critical section lengths.
The Embassy model
use embassy_executor::Spawner;
use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, channel::Channel};
use embassy_time::{Duration, Ticker};
static EVENTS: Channel<ThreadModeRawMutex, u32, 8> = Channel::new();
#[embassy_executor::task]
async fn producer() {
let mut ticker = Ticker::every(Duration::from_millis(100));
let mut n = 0u32;
loop {
ticker.next().await;
n += 1;
EVENTS.send(n).await;
}
}
#[embassy_executor::task]
async fn consumer() {
loop {
let n = EVENTS.receive().await;
let _ = n;
}
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
spawner.spawn(producer()).unwrap();
spawner.spawn(consumer()).unwrap();
}Tasks suspend at .await, and the executor sleeps the core when everything waits. Sharing is through channels, signals and mutexes in embassy-sync. A task that computes for 5 ms without awaiting delays every other task on its executor by 5 ms.
Side by side
| RTIC | Embassy | |
|---|---|---|
| Scheduling | hardware priorities, preemptive | cooperative per executor |
| Resource safety | compile time ceilings | channels and mutex types |
| Latency analysis | direct from priorities | depends on longest await-free section |
| Waiting on I/O | callbacks or async tasks | native .await |
| Drivers | embedded-hal crates | async HALs for many chips plus USB, networking |
| Heap | none | none (optional) |
| Learning curve | macros and priority thinking | async Rust and pinned futures |
Getting preemption in Embassy
Run a second executor on a software interrupt at a higher priority and spawn urgent tasks there. They preempt the main executor's tasks, like an RTIC priority level. This works but you reason about it by hand; RTIC does it with proofs.
How to choose
Pick RTIC when deadlines are hard (motor control, protocol timing), you want to prove sharing is safe, and tasks are mostly event driven. Pick Embassy when firmware is dominated by waiting on peripherals, timers and networking, and you value its drivers. Mixed designs are fine. On the interrupts-and-shared-state fundamentals both build on, read interrupts and shared state, and for an Embassy project from scratch, the Pico setup.


