Building Async Executors for Rust Embedded Systems Without std

The first time I tried to port an async firmware prototype from a Linux dev board to a Cortex-M0+, I did the naive thing: pulled in async-std, hit a wall of missing symbols, and spent an afternoon staring at linker errors about std::thread::park before it clicked that I wasn’t missing a feature flag — I was missing an entire operating system. That’s the moment every embedded Rust developer eventually has. Async/await syntax is part of the language core, but the machinery that actually runs a future — the executor — assumes a world with heap allocators, OS threads, and a scheduler underneath. On bare metal, none of that exists by default, and you have to build it yourself.

This isn’t a “hello world” async tutorial. If you already know what Future and Poll are, and you’re trying to figure out how to run async code on an MCU with 32 KB of RAM and no operating system, this is for you.

Why std-based async runtimes don’t fit on bare metal

Tokio and async-std are built around a set of assumptions that map cleanly onto a desktop or server: a heap allocator that never fails silently, OS threads for parking and waking, epoll/kqueue for I/O readiness, and a scheduler that can spawn tasks dynamically without anyone thinking about worst-case memory usage. Pull any of those runtimes into a no_std target and the compiler starts rejecting things almost immediately — not because the async syntax is unavailable, but because the runtime’s internals reach for `std::thread`, `std::sync::Mutex` backed by futex syscalls, or dynamic allocation patterns that assume an allocator will basically never return `None`.

core::future::Future itself is `no_std`-friendly — it’s just a trait with a `poll` method. The trait compiles fine on a target with no operating system at all. What you lose is everything around the trait: the part that decides when to call `poll`, how to construct a `Waker`, and how a task gets woken up again after it returns `Poll::Pending`. That’s the part you have to write yourself, and it’s smaller than people expect once you strip away the general-purpose flexibility a desktop runtime needs.

The three things a bare-metal executor actually needs

Strip an executor down to its essentials and you’re left with three responsibilities: storing task state somewhere, constructing a `Waker` that can flag a task as ready without allocating, and a polling loop that runs when there’s work to do and sleeps (or `WFI`s) when there isn’t. Everything else — priority scheduling, work-stealing, task cancellation — is optional complexity you add later, if your project actually needs it.

1. Task storage without a heap

On desktop, you’d `Box` a future and stick it in a `Vec`. On a target with no allocator, you need a fixed-size, statically allocated slot for each task. The cleanest pattern I’ve used in production firmware is a `const`-generic array of `MaybeUninit` slots, sized at compile time for the maximum number of concurrent tasks you’ll ever spawn:

use core::future::Future;
use core::mem::MaybeUninit;
use core::task::{Context, Poll};

pub struct TaskSlot<F: Future> {
 future: MaybeUninit<F>,
 taken: bool,
}

pub struct StaticExecutor<F: Future, const N: usize> {
 slots: [TaskSlot<F>; N],
}

This trades flexibility for predictability: if you try to spawn a task and every slot is full, you get a compile-time-bounded, deterministic failure instead of an OOM panic three weeks into a field deployment. For firmware that has to run unattended, that trade is almost always worth it.

Deep Dive
Rust FFI: Hidden Costs

Rust FFI: The Hidden Costs The Rust is blazing fast and memory-safe—or so you think. The moment you start banging it against C, C++, or other languages via FFI, reality hits. Your super fast Rust...

2. A Waker without Arc

This is the part that trips up most people coming from std, including me the first time. `std::task::Wake` gives you a convenient `Arc`-based helper, but `Arc` needs an allocator and atomic refcounting overhead you may not want to pay. Under the hood, a `Waker` is just a pointer and a `RawWakerVTable` — four function pointers for clone, wake, wake_by_ref, and drop. If your task’s wake target is a single static flag rather than something that needs reference counting, you can build a waker that does zero allocation and zero refcounting:

use core::task::{RawWaker, RawWakerVTable, Waker};
use core::sync::atomic::{AtomicBool, Ordering};

static TASK_READY: AtomicBool = AtomicBool::new(false);

unsafe fn noop_clone(_: *const ()) -> RawWaker {
 RawWaker::new(core::ptr::null(), &VTABLE)
}
unsafe fn wake(_: *const ()) {
 TASK_READY.store(true, Ordering::Release);
}
unsafe fn wake_by_ref(p: *const ()) {
 wake(p);
}
unsafe fn drop_fn(_: *const ()) {}

static VTABLE: RawWakerVTable =
 RawWakerVTable::new(noop_clone, wake, wake_by_ref, drop_fn);

fn static_waker() -> Waker {
 unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) }
}

This is deliberately blunt — one global flag for one task. It’s the right tool for a single-task executor driving, say, a Bluetooth stack’s main loop, and the wrong tool the moment you have more than a handful of independent tasks, because now every wake sets the same flag and you lose the information about which task became ready. That’s the fork in the road: single static flag executors are trivial to write and reason about, but they don’t scale past a couple of tasks without turning into a polling loop that re-checks everything on every wake, burning cycles you don’t have on a battery-powered device.

3. The polling loop, and where WFI comes in

Once you have task storage and a waker, the loop itself is almost anticlimactic:

loop {
 if TASK_READY.swap(false, Ordering::Acquire) {
 let waker = static_waker();
 let mut cx = Context::from_waker(&waker);
 match future.poll(&mut cx) {
  Poll::Ready(_) => break,
  Poll::Pending => {}
 }
 } else {
 cortex_m::asm::wfi(); // sleep until next interrupt
 }
}

That `wfi()` call is the entire point of doing this on bare metal instead of just spinning. Wait For Interrupt drops the core into a low-power state until the next interrupt fires — which, on a properly designed system, is exactly the same interrupt that will set your wake flag. Get this part wrong and you’ll have an executor that’s functionally correct but burns milliamps spinning in a busy-loop, which on a coin-cell-powered sensor node is the difference between a two-year deployment and a two-week one.

alloc vs strict no_std+no_alloc: pick your battle deliberately

There’s a real fork here, and I’ve shipped firmware on both sides of it. If your target has enough RAM to spare for a heap and you bring in something like embedded-alloc or linked_list_allocator, you get back a lot of ergonomics: `Box` for dynamic task spawning, `alloc::sync::Arc` if you actually need shared ownership, and code that reads a lot closer to std-flavored async. The cost is fragmentation risk over long uptimes and less predictable worst-case memory behavior — a heap allocator on a device that runs for eight months between resets can fragment in ways that are miserable to debug in the field.

Strict `no_std` without `alloc` forces everything static: fixed task counts known at compile time, `const` generics instead of `Vec`, and futures whose maximum size you have to reason about explicitly, because there’s no `Box` to hide the size behind a pointer. I lean toward this option for anything safety-adjacent or anything that has to run unattended for years, and reach for `alloc` when I’m building a more general-purpose embedded Linux-adjacent service — think an isolated microservice on a constrained edge gateway rather than a sensor MCU — where the RAM budget is generous enough that fragmentation risk is manageable.

Technical Reference
Rust Development

Rust Development Tools: From Cargo to Production-Grade Workflows Most teams adopt Rust for its safety guarantees, then spend the next six months fighting compile times, misconfigured linters, and a debugger that doesn't speak "borrow checker."...

The size_of trap nobody warns you about

Here’s something that genuinely surprised me the first time I profiled a firmware image: async fn state machines are not free. Every `.await` point inside an async function becomes a variant in a compiler-generated enum, and that enum has to be large enough to hold the largest combination of live local variables across all suspension points. Nest a few async functions that each hold moderately sized buffers across an await point, and the outer future’s size can balloon well past what you’d guess from reading the source.

async fn read_sensor(buf: &mut [u8; 64]) -> u16 { /* ... */ 0 }

async fn process() -> u16 {
 let mut buf = [0u8; 64];
 read_sensor(&mut buf).await
}

fn main() {
 println!("{}", core::mem::size_of_val(&process()));
 // often noticeably larger than 64 bytes — the compiler
 // has to account for every live variable at every await point
}

On a target with 20–32 KB of RAM total, a handful of futures with generously sized stack buffers held across await points can quietly eat a third of your memory budget before you’ve written a single line of business logic. The fix isn’t clever — it’s disciplined: keep large buffers out of scope across await points where you can, pass references to statically allocated buffers instead of owning them inside the future, and actually run `core::mem::size_of_val` on your top-level futures during development rather than finding out from a stack overflow in the field.

Interrupt-driven wakeups: the part that actually needs care

In a hosted environment, waking a task from an interrupt isn’t a special case — it’s just another thread calling into the runtime. On bare metal, the wake call happens from inside an ISR, and that context comes with hard constraints: no allocation, no blocking, and in most Cortex-M setups, no panicking without taking the whole system down. This is where the `critical-section` crate earns its keep — it gives you a portable way to guard shared state between interrupt and main context without requiring a specific concurrency primitive, and it’s become something close to a de facto standard across the embedded Rust ecosystem specifically because hand-rolled `unsafe` critical sections tend to accumulate subtle bugs as a codebase grows.

The wake flag pattern from earlier is intentionally interrupt-safe by construction: an `AtomicBool` store from an ISR is sound, doesn’t allocate, and can’t panic. The moment you’re tempted to do anything more elaborate inside a wake call — logging, dynamic allocation, anything that touches a mutex that might already be held by the main loop — you’ve introduced a priority inversion risk or a deadlock that will show up rarely enough in testing to make it into production.

What embassy gets right, and why you’d still roll your own

It would be dishonest to write about no_std async executors without pointing at embassy-rs, the project Dario Nieuwenhuis built into what’s now the closest thing the embedded Rust world has to a standard async runtime. Embassy’s executor uses an arena of statically allocated task storage sized at compile time — conceptually similar to the `TaskSlot` pattern above, but generalized and hardened across dozens of chip families — plus a `#[embassy_executor::task]` macro that handles the unsafe waker plumbing so application code never touches a `RawWakerVTable` directly. It’s also worth reading alongside RTIC (Real-Time Interrupt-driven Concurrency), the framework led by Emil Fresk, which takes a different architectural bet — static priority-based scheduling resolved at compile time rather than a cooperative async executor — and is worth understanding specifically because the contrast clarifies what async buys you and what it costs.

Worth Reading
Rust Debugging

When Rust Lies: Debugging Memory, CPU and Async Failures in Prod Memory safety guarantees get you to production. They don't keep you there. Rust's ownership model eliminates entire categories of bugs — use-after-free, data races,...

So when does it make sense to write your own instead of just depending on embassy? In my experience, three cases: you’re building something genuinely unikernel-adjacent where pulling in a general-purpose executor’s abstraction layer costs you cycles or flash space you can’t spare; you’re building an isolated microservice runtime where the “embedded” part is really about running untrusted or sandboxed code with a minimal trusted computing base, and every dependency is an audit surface; or — honestly the most common real reason — you’re learning, and building a minimal executor from scratch is one of the fastest ways to actually understand what `Future`, `Waker`, and `Pin` are doing under the hood, instead of treating them as syntax that async/await magically handles for you.

Common pitfalls worth knowing before you hit them

A few failure modes show up often enough in this space that they’re worth naming directly rather than letting you discover them the hard way. Forgetting to call `wake()` on some code path is the classic one — the task returns `Poll::Pending`, nothing ever flags it ready again, and you get a firmware that silently stops making progress on one subsystem while the rest of the device looks fine. Deeply nested async functions with sizable local state on a small-stack MCU can blow the stack in ways that are hard to correlate back to the async call chain, because the failure surfaces as a hard fault somewhere unrelated. And without any scheduling discipline, a naive polling loop treats every ready task as equally urgent — fine for two tasks, actively dangerous once you’ve got a safety-relevant control loop sharing a queue with a low-priority telemetry uploader.

Where this leaves you

None of this is an argument against using embassy or RTIC for production firmware — for the overwhelming majority of projects, one of them is the right call, and reinventing a hardened executor is a poor use of engineering time. But understanding what’s actually happening underneath `#[embassy_executor::task]` — the waker vtable, the static task arena, the interrupt-safe wake path — turns async Rust on embedded from a framework you trust into a mechanism you understand, and that distinction tends to matter a lot the first time something behaves strangely at 3 AM and the framework’s abstractions aren’t giving you an obvious answer.

Written by:

Source Category: Rust Engineering