Embassy: async embebido en Rust sin std ni RTOS

Un microcontrolador tiene kilobytes de RAM, a veces unos pocos megas si tienes suerte. No hay sistema operativo, no hay scheduler, no hay malloc de toda la vida. Solo tú, el hardware y un bucle infinito.

El código C embebido clásico tiene dos formas de manejar la concurrencia: o haces polling en un bucle enorme con flags globales, o metes un RTOS como FreeRTOS o Zephyr y gestionas tareas con stacks propios. Las dos funcionan, pero las dos tienen su coste. El polling es difícil de mantener en cuanto el proyecto crece. Y un RTOS añade complejidad, configuración, y consume RAM en los stacks de cada tarea.

Rust llegó al mundo embebido hace años con el atributo #![no_std], que desactiva la librería estándar y permite compilar sin depender de ningún sistema operativo. Sin std no tienes Vec, ni String, ni HashMap, pero sí tienes el compilador, el sistema de tipos y el borrow checker. Para abstraer el hardware se usa embedded-hal, un conjunto de traits que define cómo hablar con GPIO, SPI, I2C o UART de forma genérica. Escribes un driver para un sensor y funciona con cualquier MCU que implemente esos traits.

Hasta aquí, todo bien. Pero seguía faltando algo para manejar varias cosas a la vez sin un RTOS. Y ahí entra Embassy.

Qué es Embassy

Embassy es un framework async para microcontroladores en Rust. Su propuesta es sencilla: te deja usar async/await en hardware sin heap ni sistema operativo, con un executor que funciona directamente sobre las interrupciones del MCU.

El executor de Embassy es el equivalente a Tokio en el mundo embebido, pero sin las asunciones de Tokio. No hay heap. No hay threads del sistema operativo. Lo que hay es un scheduler cooperativo que sabe cómo registrar wakers sobre los timers e interrupciones del hardware. Cuando una tarea async espera algo, como la recepción de un byte por UART, el executor la aparca y pone a correr otra. Cuando llega la interrupción, la tarea se reactiva.

Embassy tiene soporte oficial para las familias más habituales:

  • STM32 (prácticamente todas las familias, con PAC generado automáticamente)
  • nRF52 (los chips de Nordic, muy usados en BLE)
  • RP2040 (la Raspberry Pi Pico)
  • ESP32 (soporte parcial, en desarrollo activo)

Por qué async tiene sentido en embebido

Puede sonar raro usar async/await en un MCU, pero tiene bastante sentido cuando lo piensas.

Sin async, para esperar a que un periférico responda tienes dos opciones: polling en un bucle o manejar interrupciones con callbacks y flags globales. El polling es fácil de escribir pero consume ciclos haciendo nada. Las interrupciones son eficientes pero el código se fragmenta: tienes la lógica repartida entre el handler de la interrupción y el bucle principal, con variables compartidas en medio.

Con Embassy escribes esto:

uart.read(&mut buf).await;

Y eso es todo. El compilador genera una máquina de estados en tiempo de compilación. Sin heap, sin dynamic dispatch, sin overhead de RTOS. La tarea queda suspendida hasta que llegan los datos, y mientras tanto el executor puede ejecutar otras tareas. Es zero-cost de verdad: el binario resultante es tan compacto y rápido como el código C equivalente con interrupciones.

Configurar un proyecto Embassy

Lo primero es elegir el crate de hardware para tu MCU. Para STM32 usarás embassy-stm32, para nRF52 es embassy-nrf y para la Raspberry Pi Pico es embassy-rp. Todos dependen de embassy-executor, que es el núcleo del framework.

Un Cargo.toml básico para RP2040 tiene esta pinta:

[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m", "executor-thread"] }
embassy-rp = { version = "0.2", features = ["defmt", "time-driver"] }
embassy-time = "0.3"
embassy-usb = "0.3"
defmt = "0.3"
defmt-rtt = "0.4"
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"

El archivo memory.x define el mapa de memoria del chip (dónde empieza la flash, dónde empieza la RAM, cuánto hay de cada cosa). Lo proporciona el propio crate de embassy para tu MCU, así que normalmente no tienes que escribirlo a mano.

El punto de entrada cambia respecto al Rust normal. Nada de fn main(): usas el atributo #[embassy_executor::main]:

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::{Duration, Timer};
use defmt::*;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    let mut led = Output::new(p.PIN_25, Level::Low);

    loop {
        led.set_high();
        Timer::after(Duration::from_millis(500)).await;
        led.set_low();
        Timer::after(Duration::from_millis(500)).await;
    }
}

Eso es un blink en Embassy. Si vienes de C embebido te resultará familiar en estructura, pero sin el HAL_Delay bloqueante ni el _delay_ms de AVR.

Dos tareas concurrentes sin RTOS

La gracia de Embassy es que puedes tener varias tareas corriendo a la vez sin un RTOS. Supón que quieres parpadear un LED a 1 Hz y al mismo tiempo leer un botón para cambiar la frecuencia:

#[embassy_executor::task]
async fn blink_task(mut led: Output<'static>, mut period: u64) {
    loop {
        led.set_high();
        Timer::after(Duration::from_millis(period)).await;
        led.set_low();
        Timer::after(Duration::from_millis(period)).await;
    }
}

#[embassy_executor::task]
async fn button_task(mut btn: Input<'static>) {
    loop {
        btn.wait_for_falling_edge().await;
        info!("Botón pulsado");
        // aquí cambiarías la frecuencia via un canal o un Mutex
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    let led = Output::new(p.PIN_25, Level::Low);
    let btn = Input::new(p.PIN_15, Pull::Up);

    spawner.spawn(blink_task(led, 500)).unwrap();
    spawner.spawn(button_task(btn)).unwrap();
}

Las dos tareas se ejecutan de forma cooperativa. El executor cede el control cada vez que una tarea hace .await. Para compartir estado entre tareas Embassy proporciona primitivas como Mutex, Channel y Signal, todas libres de heap y pensadas para este entorno.

El código equivalente en C con FreeRTOS tendría dos tareas con stacks propios, un xQueueCreate, varios callbacks de GPIO y bastante más superficie de error. No digo que sea imposible, pero es más difícil de leer y de depurar.

embassy-time: temporización sin bloqueos

El crate embassy-time te da lo que necesitas para trabajar con tiempo sin quedarte atascado en un delay bloqueante:

// Esperar 500ms sin bloquear el MCU
Timer::after(Duration::from_millis(500)).await;

// Ejecutar algo cada segundo
let mut ticker = Ticker::every(Duration::from_secs(1));
loop {
    ticker.next().await;
    // aquí va lo que quieres hacer cada segundo
}

// Timeout: si no llegan datos en 2s, continúa
let result = with_timeout(Duration::from_secs(2), uart.read(&mut buf)).await;

Por dentro, embassy-time usa el hardware timer del MCU para generar la interrupción cuando vence el plazo. No hay polling, no hay bucle de espera. Si el MCU no tiene nada más que hacer, entra en un modo de bajo consumo hasta que llega la interrupción. Esto es especialmente útil en dispositivos con batería.

embassy-usb: USB desde Rust

Implementar USB en C embebido es de las cosas más tediosas que hay. La especificación USB es enorme, los descriptores son un formato binario que se construye a mano y cualquier error te da un dispositivo que el host ignora sin decirte por qué.

Embassy tiene embassy-usb, que implementa el stack USB encima de los periféricos USB de los MCU compatibles. Para hacer un dispositivo CDC (puerto serie virtual sobre USB):

let driver = Driver::new(p.USB, Irqs);
let mut config = Config::new(0x1234, 0x5678);
config.manufacturer = Some("Mi dispositivo");
config.product = Some("Puerto serie USB");

let mut builder = Builder::new(driver, config, &mut config_descriptor, &mut bos_descriptor, &mut msos_descriptor, &mut control_buf);

let mut class = CdcAcmClass::new(&mut builder, &mut state, 64);
let usb = builder.build();

// Ahora puedes leer y escribir como si fuera un UART
class.write_packet(b"Hola desde USBrn").await.unwrap();

Hay clases implementadas para HID (teclados, ratones, gamepads), Mass Storage (aparecer como una unidad USB) y audio. No tienes que entender el protocolo USB para usarlas, solo configurar los descriptores con los parámetros de tu dispositivo.

Embassy vs FreeRTOS

FreeRTOS es maduro, está probado en producción en millones de dispositivos y tiene soporte para casi cualquier MCU que exista. Las tareas son threads con stacks propios, el API es en C y hay un ecosistema amplio de middlewares certificados para uso industrial y médico.

Embassy apuesta por otro modelo. Las tareas async no tienen stacks propios: el compilador genera máquinas de estados que comparten el stack principal. Esto se traduce en menos consumo de RAM, porque no reservas 512 bytes por tarea de FreeRTOS aunque la mayoría esté dormida.

La comparativa concreta:

  • RAM: Embassy gana en proyectos con varias tareas, al no tener stacks independientes por tarea.
  • Determinismo temporal: FreeRTOS tiene prioridades de tareas con preempción. Embassy es cooperativo, sin preempción (aunque puedes usar embassy-sync con interrupciones para casos críticos).
  • Curva de entrada: FreeRTOS requiere conocer C y el modelo de tareas del RTOS. Embassy requiere conocer Rust, lo que ya es una barrera considerable.
  • Seguridad: el borrow checker de Rust elimina toda una categoría de bugs de acceso concurrente que en C tienes que gestionar tú.
  • Madurez: FreeRTOS lleva décadas. Embassy lleva pocos años y la API todavía cambia entre versiones menores.

Si ya sabes Rust y empiezas un proyecto nuevo en un MCU compatible, Embassy es una opción muy sólida. Si tienes código C existente o necesitas certificaciones que requieren RTOS auditados, FreeRTOS sigue siendo la elección obvia.

Recursos para empezar

La documentación oficial de Embassy está en embassy.dev/book e incluye ejemplos para cada familia de MCUs. Los ejemplos del repositorio de GitHub son especialmente útiles porque hay uno para casi cada periférico.

Si quieres entender el contexto más amplio de Rust en embebido, puedes leer sobre Rust en sistemas embebidos: panorama 2025 o las novedades de Rust 1.95 y las mejoras para no_std, que afectan directamente al desarrollo embebido.

El punto de entrada más práctico es una Raspberry Pi Pico (RP2040): cuesta menos de cinco euros, tiene soporte excelente en Embassy y no necesitas un programador externo, solo un cable USB.

Imagen: Pexels / Craig Dennis

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP