Rust Embedded for IoT Firmware: Embassy Async vs Zephyr vs FreeRTOS on Cortex-M (2026)

Rust Embedded for IoT Firmware: Embassy Async vs Zephyr vs FreeRTOS on Cortex-M (2026)

Embassy Rust Embedded vs Zephyr vs FreeRTOS: IoT Firmware Scheduling and Power on Cortex-M (2026)

Most IoT firmware spends well over 99 percent of its life doing nothing. A soil sensor wakes, reads an ADC, sends a packet and sleeps again. The interesting engineering question is therefore not how fast the code runs, but how cheaply the system can wait, how safely it can be woken, and how many bugs the language lets through while it does. Three answers dominate Cortex-M projects in 2026: a classic preemptive RTOS such as FreeRTOS, the batteries-included Zephyr project, and the newer cooperative async model of Embassy Rust embedded firmware.

This matters now because the ground has shifted under all three. Rust’s embedded traits reached 1.0, a qualified Rust toolchain exists for safety work, Zephyr gained an optional Rust module, and regulators in several markets are asking vendors about memory safety. Choosing a firmware foundation has become a decision about scheduling model, ecosystem and risk, not just syntax.

You will leave with a mental model of how each approach schedules work, a worked memory and wakeup argument, a runnable Embassy example, and a decision matrix you can defend in a design review.

What this covers: the executor and waker mechanism, embedded-hal 1.0 and its async twin, RTIC, Zephyr’s Rust support, FreeRTOS, flash and RAM reasoning, interrupt latency, low-power behaviour, drivers, tooling, safety status, and a selection matrix.

Context and Background

Bare-metal C with a super-loop was the original microcontroller architecture. It is simple, but every blocking delay stalls everything else, and adding a second concern means hand-rolling a state machine. Real-time operating systems fixed that by giving each activity its own thread and stack, with a kernel timer tick deciding who runs. FreeRTOS, first released in 2003 and now stewarded by Amazon under the MIT license, is the most widely deployed example. Zephyr, a Linux Foundation project, takes the same threaded model and adds a large device model, networking and Bluetooth stacks, and a build system built on devicetree and Kconfig.

Rust entered embedded work through the Rust Embedded working group, which standardised a layered ecosystem: peripheral access crates generated from vendor SVD files, hardware abstraction layers on top, and the embedded-hal traits that let a driver be written once and used on any chip. For a long time the missing piece was concurrency. Interrupt-safe sharing through critical sections worked but was awkward, and the borrow checker pushed designers toward patterns that felt unfamiliar to RTOS veterans.

Two projects answered that gap. RTIC (Real-Time Interrupt-driven Concurrency) maps tasks directly onto hardware interrupt priorities and the Cortex-M NVIC, using compile-time analysis of shared resources. Embassy, which describes itself as a modern embedded framework, went the other way and brought async/await to microcontrollers, with an executor, timers, and HALs for STM32, nRF, RP2040 and others. Embassy’s own documentation states that its executor needs no heap and no alloc, and that the CPU sleeps using interrupts or the WFE/SEV instructions when there is no work (embassy-executor README).

If you are new to the RTOS side of this comparison, our earlier piece on Zephyr vs FreeRTOS vs NuttX for industrial RTOS selection covers certification and ecosystem in more depth. This article focuses on the scheduling and power model, and on where Rust changes the calculus.

Scheduling Models Side by Side: Threads, Interrupts and Futures

Direct answer: FreeRTOS and Zephyr give each task a dedicated stack and let a preemptive kernel switch between them. Embassy compiles each task into a state machine that shares one stack and yields only at .await points. RTIC dispatches tasks as interrupt handlers at hardware priorities. The first costs RAM per task, the second costs run-to-completion discipline, the third costs portability.

Embassy rust embedded executor and waker loop on a Cortex-M microcontroller

Figure 1: The Embassy executor loop. Interrupts call wakers, wakers enqueue tasks, and when the queue is empty the core executes WFE or WFI and sleeps until the next interrupt.

The diagram shows the heart of async embedded Rust. A peripheral raises an interrupt. The interrupt handler, usually inside the HAL crate, calls the wake method on a stored Waker. That marks the owning task as ready and pushes it onto the executor’s run queue. When the handler returns, the executor’s poll loop drains the queue, polling each woken future until it returns Pending or Ready. Only when the queue is empty does the executor issue a sleep instruction.

How an Embassy task actually runs

An async fn in Rust compiles to a state machine implementing the Future trait. Each .await is a suspension point at which the compiler has already worked out exactly which locals must survive, so the saved state is only that set. There is no separate stack to switch, no context-switch assembly, and no kernel object per task. The cost is that the future’s size is fixed at compile time and lives in static memory.

Embassy’s documentation describes two allocation paths. On nightly Rust with the nightly feature, each task’s future is placed in its own static sized by the compiler. Without nightly, tasks come from a bump arena whose size is set by a Cargo feature or the EMBASSY_EXECUTOR_TASK_ARENA_SIZE environment variable, and the documentation notes it panics at runtime if the arena fills. Check the current README for which path applies to your toolchain, because this area has evolved between releases.

Wakers: the contract between hardware and executor

A Waker is a small handle, essentially a pointer plus a vtable, that says “poll this task again”. The Rust Future contract is precise: when poll returns Pending, the future must have arranged for someone to call the waker when progress is possible, or the task will never run again. In Embassy, peripheral drivers register the waker in a per-peripheral slot (often an AtomicWaker) and the interrupt handler fires it.

That contract is also the main correctness hazard. A driver that forgets to register the waker, or registers it after checking the condition and before enabling the interrupt, creates a lost-wakeup race. The standard defence is to register first, then re-check the condition, then return Pending. Well-tested HAL code does this for you; code you write against raw registers must repeat the discipline.

Cooperative scheduling and what it means for latency

Within one executor, tasks are cooperative. A task that spins for five milliseconds without awaiting delays every other task on that executor by five milliseconds. This is the principal trade against a preemptive RTOS, where a higher-priority thread preempts a lower one at the next tick or interrupt return.

Embassy’s answer, per its documentation, is to run multiple executor instances at different priorities. In the common Cortex-M pattern, a lower-priority executor runs in thread mode while a higher-priority executor is driven from a spare interrupt vector, so the NVIC itself preempts the lower-priority one. That gives hardware-priority preemption between executors while keeping cooperative, stackless tasks inside each. I describe the pattern as I understand it from the project documentation; confirm the exact type names (InterruptExecutor in current releases, to the best of my knowledge) against the version you pin.

How the preemptive RTOS model differs

In FreeRTOS, a task is a function with its own stack and a task control block. The scheduler is priority-based and preemptive by default, with optional time slicing among equal priorities. A blocking call such as xQueueReceive moves the task to a blocked list and invokes the scheduler. The tick interrupt, typically 1 kHz, wakes the kernel to manage delays; FreeRTOS offers a tickless idle mode that suppresses the tick so the MCU can stay asleep longer, but it is a configuration you must enable and validate.

Zephyr’s kernel is similar in spirit: threads with priorities (cooperative and preemptible ranges both exist), plus workqueues, message queues, semaphores and a tickless kernel option on supported targets. Its distinguishing feature is not the scheduler but everything around it: a uniform device driver model, a devicetree description of the hardware, and subsystems for networking, USB, Bluetooth, storage and power management.

The structural difference is memory. With N threads, an RTOS needs N stacks, each sized for the worst-case call depth of its task plus interrupt frames. With N Embassy tasks, you need N future state machines, each sized for the live variables across its await points, plus one shared stack sized for the deepest poll plus interrupts.

The Ecosystem Layer: embedded-hal 1.0, Drivers and Rust on Zephyr

Direct answer: embedded-hal is a set of traits (SPI, I2C, GPIO, delay and more) that decouple sensor and radio drivers from any specific chip. Version 1.0 stabilised them, and embedded-hal-async provides the awaitable twin that Embassy HALs implement. A driver written against these traits runs on any HAL that implements them.

embedded-hal and embedded-hal-async layering from driver to silicon for embassy rust embedded

Figure 2: The layering that makes Rust drivers portable. Drivers depend only on trait crates; HAL crates implement the traits over generated peripheral access crates.

Why embedded-hal 1.0 mattered

Before 1.0, the trait crate shipped as a long series of 0.2 releases, and breaking changes forced the whole ecosystem to maintain compatibility shims. The 1.0 release, which I recall landing in early 2024 (verify the exact date against the crate’s changelog), froze the core traits so that driver authors could depend on a stable contract. The companion crates embedded-hal-async and embedded-io cover awaitable bus transfers and byte streams respectively.

The trait design reflects hard lessons. Bus sharing is modelled explicitly: an SpiDevice owns chip-select handling and bus arbitration, while an SpiBus is the raw shared medium. That distinction removes a class of bugs where two drivers on one SPI bus interleave transactions. Error handling is likewise standardised through an ErrorType and ErrorKind mapping, so portable code can react to categories such as arbitration loss or NACK without knowing the chip.

Async drivers in practice

The practical benefit of the async twin is that a driver’s bus transfer yields to the executor instead of spinning. A temperature sensor read over I2C starts a DMA transfer, registers a waker, and returns Pending; the core sleeps or services other tasks until the DMA completion interrupt fires. The same driver source also works with a blocking HAL if it is generic over the sync traits, though crates often ship separate sync and async feature paths.

Here is a minimal generic driver sketch in that style. It is illustrative and untested; the register address and scaling belong to a hypothetical sensor.

// Illustrative sketch, untested. Hypothetical sensor at address 0x48.
use embedded_hal_async::i2c::I2c;

pub struct Tmp<I2C> { i2c: I2C }

impl<I2C: I2c> Tmp<I2C> {
    pub fn new(i2c: I2C) -> Self { Self { i2c } }

    /// Returns raw 16 bit reading from register 0x00.
    pub async fn read_raw(&mut self) -> Result<i16, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(0x48, &[0x00], &mut buf).await?;
        Ok(i16::from_be_bytes(buf))
    }
}

Nothing in that code names a vendor. Any HAL whose I2C type implements embedded_hal_async::i2c::I2c can drive it. That portability is the strongest argument for the Rust ecosystem, and it contrasts with C drivers that are usually written to one vendor SDK or one RTOS’s driver API.

The wider Embassy stack

Embassy is more than an executor. The project ships embassy-time for timers and delays (a global time driver backed by a hardware timer), embassy-sync for channels, signals, mutexes and pipes that are executor-aware, embassy-net for a TCP/IP stack, embassy-usb, and embassy-boot for a bootloader with swap-based update. HAL crates such as embassy-stm32, embassy-nrf and embassy-rp implement peripherals as async-capable drivers. The convenience is that one project provides executor, drivers, network and bootloader that were designed together.

The cost is breadth of hardware support. Embassy supports the families its maintainers and contributors target; it does not match the several hundred boards upstream Zephyr lists. If your silicon vendor ships only a C SDK with a binary radio blob, Embassy may not be an option without writing a PAC and HAL yourself.

RTIC as the third Rust option

RTIC deserves a fair hearing. It treats Cortex-M interrupt priorities as the scheduler. Tasks are declared with priorities, shared resources are declared with locks, and the framework statically computes ceiling priorities using the Stack Resource Policy so that deadlock is impossible and priority inversion is bounded by construction. Newer RTIC releases also support async tasks, narrowing the gap with Embassy.

Its strength is analysability: worst-case blocking is computable from the source. Its weakness is that it is tied to interrupt controllers it supports (Cortex-M and RISC-V variants, to my knowledge), and it gives you scheduling only; you bring your own HAL, network stack and timers. Many teams therefore use RTIC for hard-deadline control loops and Embassy where wide peripheral support and async I/O dominate. Both can coexist on one chip in principle, though mixing them is advanced work.

Rust on Zephyr

Zephyr’s official documentation describes Rust as an optional module. Enabling it means adding the zephyr-lang-rust project to the west manifest filter, setting CONFIG_RUST, using rust_cargo_application() in CMake, and building a staticlib crate that depends on the zephyr crate. The documentation says only a few targets support Rust today, with the list kept in the module’s platforms.txt, and that the package name must currently be rustapp (Zephyr Rust support). The page does not mention async support or Embassy, so I will not claim any.

What this gives you is the Zephyr kernel, networking and device model, with application logic in safe Rust calling Zephyr APIs through wrapper crates. What it does not give you is the Embassy executor model; threads remain Zephyr threads with Zephyr stacks. Treat it as a way to write Zephyr applications in Rust, not as a fusion of the two worlds. Readers weighing kernel-level Rust should also see our analysis of Rust in the Linux kernel and its implications for embedded and IoT drivers, which covers a different layer of the same trend.

Worked Example: Memory, Flash and Wakeup Reasoning

Direct answer: RTOS RAM scales with thread count times stack size, while Embassy RAM scales with the sum of task state machines plus one stack. Flash differences are dominated by what you link, not by the scheduler. All figures below are illustrative arithmetic, not measurements; measure your own build with cargo size, arm-none-eabi-size and stack watermarking.

RAM arithmetic

Stack models compared for Embassy, Zephyr and FreeRTOS in embassy rust embedded firmware

Figure 3: Each RTOS thread owns a stack, whereas Embassy futures share one stack under a single executor.

Suppose a gateway has eight concurrent activities: sensor polling, BLE, MQTT, OTA download, watchdog service, logging, a button handler and a display refresh. On an RTOS you would typically create a thread for most of them. Assume a conservative illustrative stack of 1 KiB for simple threads and 3 KiB for the network and TLS threads. With six simple and two heavy threads that is about 12 KiB of stack, plus a kernel control block per thread (tens to a hundred or so bytes each, depending on kernel and configuration), plus kernel heap for queues.

On Embassy the same eight activities are eight futures. A future’s size is the largest set of locals live across any await, and for an I/O-bound task that is often tens to a few hundred bytes plus any buffers you hold across the await. TLS still needs working memory, and a large receive buffer held across an await sits inside the future. The shared stack must cover the deepest single poll, which with TLS handshakes can still be several KiB. The saving is real for the many small tasks, and negligible for the one or two heavy ones. Treat claims of “Embassy uses a tenth of the RAM” with suspicion; the saving depends on how many small tasks you have.

Flash reasoning

Flash is mostly code you call: protocol stacks, crypto, drivers and formatting. Rust adds monomorphised generics, which can inflate size when a generic driver is instantiated several times, and panic and formatting machinery, which defmt and panic-halt or panic-probe reduce considerably. Zephyr’s footprint depends on the Kconfig options you enable, and a minimal build can be small while a Bluetooth plus networking build is much larger. FreeRTOS’s kernel itself is compact, and total size is mostly what you add around it. The honest rule: compare builds with equivalent features (same TLS library, same MQTT client, same logging), using release profiles with link-time optimisation, because feature mismatch swamps any scheduler difference.

Interrupt latency

Interrupt latency to the first instruction of a handler is a property of the Cortex-M core and the NVIC, identical across all three if the handler is installed directly. Arm documents typical interrupt entry latencies for Cortex-M cores in the low tens of cycles; check the technical reference manual for your exact core. What differs is latency to task-level work. In an RTOS, a handler gives a semaphore, the scheduler runs on exit via PendSV, and the woken thread starts after a context switch. In Embassy, a handler calls a waker, and the work starts when the executor next polls, which on a single cooperative executor means after whatever task is currently running yields.

Two consequences follow. First, keep handlers short in every model, doing only acknowledgement and wake. Second, if one task needs bounded response time, put it on a higher-priority executor or in RTIC, rather than hoping cooperative tasks behave. Cooperative scheduling shifts the responsibility from the scheduler to the programmer’s review process, and a static analysis that bounds time between awaits is worth having.

Low-power behaviour

All three can reach deep sleep, with different defaults. Embassy’s executor sleeps whenever its queue is empty, which makes sleep the default state rather than a tuning exercise. The sleep instruction used depends on the configuration; per the README it uses interrupts or WFE/SEV. WFI (wait for interrupt) halts the core clock until an interrupt, and WFE waits for an event, which also wakes on SEV and, with the right setting, on pending interrupts. Deeper states, such as STM32 Stop modes or nRF System OFF, need explicit clock and peripheral management, and Embassy HALs expose some of this per family.

FreeRTOS enters sleep from its idle task, typically via the idle hook calling WFI, with the tick as the periodic wake source. Tickless idle (configUSE_TICKLESS_IDLE) stops the tick during long idle periods and programs a timer for the next scheduled wake; you must handle clock and peripheral state around it. Zephyr provides a tickless kernel and a power management subsystem with power states and device runtime power management, which gives structure but requires correct devicetree and driver support.

Decision flow for choosing Embassy, RTIC, Zephyr or FreeRTOS firmware

Figure 4: A decision flow for Cortex-M IoT firmware. Certification needs and connectivity breadth come first; team size and deadline structure refine the choice.

A sleep example makes the stakes concrete. If a node draws an illustrative 2 microamps asleep and 5 milliamps for 20 ms every 60 seconds, the average current is roughly 2 + (5000 x 20/60000), about 1.7 microamps awake share plus 2, near 3.7 microamps. Shaving the active window by 5 ms changes the average by roughly 0.4 microamps, while a stray peripheral left clocked in sleep, drawing perhaps a few microamps, can double the total. Sleep correctness beats micro-optimising the scheduler. The numbers are illustrative; the lesson is that leakage bugs, not scheduler overhead, dominate battery life.

Embassy in Practice: A Runnable Example and Its Failure Modes

Direct answer: an Embassy application is an #[embassy_executor::main] entry point that initialises the HAL, spawns tasks, and awaits timers or peripherals. Below is a two-task sketch for an nRF-class board. It is untested here; crate versions and pin names must be matched to your board and to the current embassy-nrf API, which changes between releases.

// Illustrative sketch, UNTESTED. Pin names and crate APIs vary by version and board.
#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{Level, Output, OutputDrive};
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
use embassy_sync::channel::Channel;
use embassy_time::{Duration, Timer};
use {defmt_rtt as _, panic_probe as _};

static READINGS: Channel<ThreadModeRawMutex, u16, 4> = Channel::new();

#[embassy_executor::task]
async fn sampler() {
    let mut n: u16 = 0;
    loop {
        n = n.wrapping_add(1);              // stand-in for an ADC read
        READINGS.send(n).await;             // yields if channel full
        Timer::after(Duration::from_secs(60)).await;
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_nrf::init(Default::default());
    let mut led = Output::new(p.P0_13, Level::High, OutputDrive::Standard);
    spawner.spawn(sampler()).unwrap();      // API shape differs across versions

    loop {
        let v = READINGS.receive().await;   // core sleeps while waiting
        info!("reading {}", v);
        led.set_low();
        Timer::after(Duration::from_millis(20)).await;
        led.set_high();
    }
}

Three details deserve explanation. The channel is statically allocated with a fixed capacity of four, so memory is known at link time and back-pressure is explicit: a full channel makes send yield. The mutex type, ThreadModeRawMutex, tells embassy-sync that all users run in thread mode on one executor, which avoids a critical section; if you share the channel with an interrupt or a second-priority executor you need CriticalSectionRawMutex instead. Choosing the wrong raw mutex is a classic soundness-versus-performance trade, and the type system helps by making the choice visible.

Failure modes that actually bite

The first is the blocking call in an async task. Calling a busy-wait delay, a blocking flash erase or a long CRC inside a task freezes every task on that executor. Symptoms look like random timeouts in unrelated subsystems. Mitigations are to use async variants, move heavy work to a lower-priority executor, or chunk it with explicit yields.

The second is lost wakeups from hand-written futures, covered above. The third is future size growth: holding a 1500-byte network buffer across an await inside a task that is itself nested in other futures can balloon the state machine, and in the arena allocation mode it can exhaust the arena at startup. Inspect sizes with compiler flags such as -Zprint-type-sizes on nightly, or by checking .bss growth.

The fourth is cancellation. In Rust, dropping a future cancels it at its last await. If that future was halfway through a multi-step bus transaction, the peripheral may be left in an undefined state. Embassy and HAL authors document which operations are cancel-safe; treat select combinators over bus transfers with care.

The fifth is priority inversion across executors. An embassy-sync mutex shared between a high-priority and low-priority executor does not implement priority inheritance, to my understanding; design shared state as message passing where possible.

Tooling: probe-rs, defmt, and Debugging Without a Vendor IDE

Direct answer: probe-rs flashes and debugs Cortex-M targets over SWD or JTAG from a single Rust-native tool, and defmt logs compactly by sending only string indices and raw values over RTT, formatting on the host.

probe-rs supports a large and growing list of chips, offers cargo embed and cargo run integration via a runner line in .cargo/config.toml, and speaks GDB and Debug Adapter Protocol for editor debugging. Real-Time Transfer (RTT) moves log bytes through a RAM buffer the probe reads, avoiding a UART and its pins entirely.

defmt (“deferred formatting”) interns format strings at build time in the ELF file, so the target transmits a small integer plus arguments. The practical effect is that logging statements cost far less flash and far fewer cycles than printf-style formatting, which makes it affordable to leave logging in production builds on constrained parts. Log levels can be compiled out through the DEFMT_LOG environment variable.

Two other tools are worth knowing. flip-link reverses the memory layout so that a stack overflow hits the end of RAM and faults instead of silently corrupting .bss and .data, converting a heisenbug into a hard fault. And cargo-bloat or cargo size attribute flash use to crates and functions, which is the right starting point for any footprint complaint.

The Zephyr workflow is different. west manages the workspace, Kconfig and devicetree configure the build, and west flash and west debug drive a runner such as OpenOCD, J-Link or pyOCD. The learning curve is steeper, and the payoff is a uniform configuration system across hundreds of boards. FreeRTOS has no mandated toolchain; vendors supply IDEs and SDKs, and debugging aids such as thread-aware views depend on the IDE or on OpenOCD awareness of FreeRTOS. For Rust under Zephyr, expect to use both Cargo and west.

Safety, Security and Certification Status

Direct answer: no mainstream Rust embedded executor is itself certified to a functional safety standard today, as far as I could verify, but the Rust compiler has a qualified distribution (Ferrocene) and part of the core library is certified. Safety-certified RTOS options exist in C, notably SafeRTOS, a derivative of FreeRTOS marketed for certified use.

What is verifiably certified

Ferrous Systems, which maintains Ferrocene, reports that the Ferrocene compiler is qualified by TÜV SÜD for ISO 26262 up to ASIL D, IEC 61508 up to SIL 3, and IEC 62304 Class C. It also reported that a subset of the Rust core library (libcore) was certified to IEC 61508 SIL 2 by TÜV SÜD, announced on 3 December 2025 and included in Ferrocene 25.11.0, covering the toolchain on qualified targets Armv7E-M and Armv8-A (Ferrous Systems announcement). Armv7E-M covers Cortex-M4 and M7 parts, which is why this matters directly for the MCUs in this article. I could not verify dates for the compiler qualification itself from the pages retrieved, so I give none.

Qualification of a compiler is not certification of your product. You still need a safety case, requirements traceability, testing and, for Embassy or any other library you link, evidence for that library’s behaviour. As of my research, I found no statement that Embassy’s executor or HALs carry a safety certificate, so treat that as an assumption to confirm with the project and your assessor.

RTOS side

SafeRTOS is a separately developed, pre-certified relative of FreeRTOS sold by WITTENSTEIN high integrity systems, and is the usual route when a FreeRTOS-style API must be certified. Zephyr has a safety working group pursuing certification, which I believe targets IEC 61508; verify current status with the project before relying on it. Open FreeRTOS has no certification of its own, and the safety value lies in the commercial derivative.

Security and memory safety

Rust removes use-after-free, double free and data races in safe code, which addresses a large share of historical memory-corruption CVEs in C firmware. It does not remove logic errors, protocol bugs, side channels or unsafe blocks in HALs, and every PAC contains unsafe register access. Rust also does not secure the boot chain. Signed firmware, rollback protection and key storage are separate controls; for the post-quantum angle see our comparison of SLH-DSA, ML-DSA and FN-DSA for firmware signing and secure boot. Embassy’s embassy-boot bootloader and Zephyr’s MCUboot integration both address update flow, and both need a signing policy defined by you.

Trade-offs, Gotchas, and What Goes Wrong

Honest comparisons reveal costs on every side. Embassy’s weakness is cooperative scheduling combined with a young ecosystem: fewer certified components, fewer vendor-supported radio stacks, and API churn between releases. Pinning versions and budgeting for upgrade work is a real cost. Hiring is also a factor; embedded engineers who know Rust and async well are scarcer than those who know C and FreeRTOS.

Zephyr’s weakness is weight and complexity. Devicetree and Kconfig are powerful and bewildering to newcomers, builds take time, and small simple products can feel over-engineered. Its Rust support is optional and limited to a few targets according to its own documentation, so a Rust-first Zephyr strategy needs a target check on day one.

FreeRTOS’s weakness is that it is only a kernel plus libraries. Everything else, from drivers to networking to build structure, comes from your vendor SDK, which means portability between vendors is mediocre and quality varies. Its strength is the vast installed base, long history and the availability of a certified sibling.

Cross-cutting gotchas include mixing C and Rust: FFI to a vendor blob is unsafe, and the blob’s interrupt and stack assumptions may conflict with executor design. Binary radio stacks such as Nordic’s SoftDevice historically imposed constraints on interrupt priorities and peripheral ownership; check the current guidance for your chip. Another is debug versus release: debug Rust builds on small parts can be too large and slow to be useful, so most teams set opt-level to s or higher even for development.

Finally, resist the false binary. A product can run Zephyr for connectivity on a radio core and Embassy on an application core, or use RTIC for a motor controller beside an RTOS gateway. Architecture follows the real-time structure of the problem.

Decision Matrix and Practical Recommendations

The matrix below is my judgement, not a benchmark. “Strong”, “Fair” and “Weak” reflect typical situations; your constraints may flip any row.

Criterion Embassy RTIC Zephyr FreeRTOS
Language fit Rust, async Rust C, optional Rust C
Scheduling Cooperative, multi-executor Hardware priority Preemptive threads Preemptive threads
RAM per task Strong Strong Fair Fair
Hard deadline analysis Fair Strong Fair Fair
Hardware breadth Fair Fair Strong Strong via vendors
Connectivity stacks Fair Weak Strong Fair
Low-power default Strong Strong Fair, needs tuning Fair, needs tuning
Safety certification path Weak today Weak today In progress, verify Strong via SafeRTOS
Learning curve Moderate Moderate Steep Gentle

Recommendations

Choose Embassy when the product targets one or two well-supported chip families, the team is small, the workload is I/O-bound with many concurrent but light activities, and battery life matters. Choose RTIC when worst-case response times must be analysed from the source. Choose Zephyr when you need many boards, rich connectivity or a vendor that ships Zephyr as its primary SDK. Choose FreeRTOS when you inherit a vendor SDK, need the largest talent pool, or need the SafeRTOS certification route.

Checklist before committing:

  • Confirm your exact MCU and radio are supported by the HAL or Zephyr board you intend to use, in the version you will pin.
  • Build the same minimal feature set on each candidate and compare flash and RAM yourself.
  • Measure sleep current with the radio and peripherals actually in use.
  • Write down the worst-case time between awaits for any cooperative executor.
  • Decide the update, signing and rollback design before the scheduler.
  • Ask your safety assessor which toolchain and library evidence they will accept.

Frequently Asked Questions

Is Embassy a real-time operating system?

Not in the traditional sense. Embassy is an async executor with drivers and libraries, not a preemptive kernel. Tasks within one executor are cooperative, so real-time behaviour depends on how long tasks run between awaits. Multiple executors at different interrupt priorities provide preemption between groups of tasks. For many soft-real-time IoT workloads that is sufficient; for hard deadlines you need analysis, or RTIC, or a conventional RTOS with priority scheduling.

Can I use Embassy and Zephyr together?

They are separate runtime models, and Zephyr’s Rust documentation does not mention Embassy or async support. A practical combination is on different cores or chips, for example Zephyr on a connectivity processor and Embassy on an application processor, communicating over a defined protocol. Running the Embassy executor as a Zephyr thread is conceivable but not an officially documented path that I could verify, so prototype and test carefully before depending on it.

What does embedded-hal 1.0 change for driver authors?

It gives a stable set of traits for SPI, I2C, GPIO and delays, so a driver compiled against it keeps working across HAL updates. The explicit separation between bus and device traits fixes shared-bus bugs, and the async companion crate lets one driver design serve blocking and awaitable use. Authors should publish drivers generic over the traits, avoid vendor dependencies, and gate async code behind a feature if they want to support both styles.

Is Rust ready for safety-critical firmware?

The toolchain side has progressed: Ferrous Systems reports the Ferrocene compiler is qualified to ISO 26262 ASIL D and IEC 61508 SIL 3, and a libcore subset is certified to SIL 2. The library and runtime side lags, since common executors and HALs lack published certification evidence. You can build certified products in Rust, but you will carry more of the evidence burden yourself than with a pre-certified C RTOS.

How does async Rust affect battery life?

Mainly through defaults. The Embassy executor sleeps whenever no task is ready, so firmware tends to idle correctly without extra tuning. The bigger factors remain peripheral clock gating, radio duty cycle and sleep-state selection, all of which are hardware-level choices available in any framework. Async makes idle states easy to reach; it does not guarantee the deepest state. Measure with a current profiler rather than assuming.

Should I rewrite existing FreeRTOS firmware in Rust?

Rarely all at once. A rewrite discards field-tested behaviour. A lower-risk path is to write new components, parsers and protocol handlers in Rust and link them via a C ABI, concentrating effort on code that handles untrusted input. Reassess a full migration at a hardware refresh, when drivers and board support are changing anyway and the cost of change is already paid.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *