Desarrollo & Software

Rust para sistemas embebidos: por qué es una alternativa real a C y cómo se programa un microcontrolador con él

Alejandro Paxi 07 septiembre 2026 34 min de lectura 25 vistas

Durante cuarenta años, programar un microcontrolador significó programar en C. No por inercia: C es pequeño, predecible, compila para cualquier arquitectura que exista y deja al programador tocar el hardware sin intermediarios. El problema es que también deja tocar lo que no debía. Un puntero mal calculado, un búfer que se pasa un byte, una variable compartida entre una interrupción y el bucle principal sin protección: nada de eso detiene la compilación, y en un sistema sin memoria virtual ni sistema operativo tampoco produce un error limpio. Produce un dispositivo que se reinicia cada tres días en casa de un cliente y que nadie sabe reproducir en el laboratorio.

Rust propone algo distinto: un lenguaje de sistemas, sin recolector de basura y sin runtime, que genera código comparable al de C, pero cuyo compilador se niega a producir un binario si el programa contiene esa familia de errores. La pregunta ya no es si la idea funciona —funciona, y hay coches en la calle con firmware en Rust—, sino si el ecosistema está listo para su proyecto. Este artículo intenta responderlo con código real, con la arquitectura de la pila, con las herramientas y también con las cosas que todavía no funcionan bien.

Alcance: Rust en microcontroladores sin sistema operativo (bare metal y no_std), sobre todo ARM Cortex-M y RISC-V. Los ejemplos usan la familia STM32F4 por ser la más común en el aula y en el laboratorio, pero la estructura es idéntica en nRF52, RP2350 o ESP32. Estado del ecosistema a setiembre de 2026; los nombres de método de cada HAL cambian entre versiones, así que conviene contrastar contra la documentación de la versión que use.

El problema que Rust intenta resolver

Conviene empezar por el tamaño del problema, porque de él sale toda la justificación. Microsoft revisó sus vulnerabilidades entre 2006 y 2018 y encontró que alrededor del 70 % eran fallos de seguridad de memoria; el proyecto Chromium de Google llegó a una proporción parecida; y el equipo Project Zero, analizando vulnerabilidades explotadas en el mundo real, encontró cifras del mismo orden. En diciembre de 2023 la CISA y la NSA, junto con agencias de otros cuatro países, publicaron The Case for Memory Safe Roadmaps, un documento que pide a los fabricantes de software un plan público para eliminar esta clase de fallos. No es una moda de lenguaje: es política pública de ciberseguridad.

En un servidor, un fallo de memoria produce un volcado y un reinicio del proceso. En un microcontrolador no hay unidad de gestión de memoria, no hay proceso que reiniciar y muchas veces no hay ni siquiera un canal para reportar el fallo. La pila del programa y las variables globales viven en la misma SRAM, sin ninguna frontera entre ellas; un desbordamiento de búfer sobreescribe silenciosamente otra variable y el síntoma aparece minutos después, en otro módulo, sin relación aparente con la causa. Es la peor combinación posible: consecuencias graves y diagnóstico casi imposible.

La diferencia entre los dos lenguajes se ve mejor si se piensa como una criba. Los mismos errores entran por arriba; lo que cambia es la puerta que encuentran.

C / C++
use-after-freedoble freebuffer overflowpuntero nulocarrera ISR/mainalias mutableperiférico duplicadoerror de lógica
El compilador no lo ve
Todo pasa. El fallo aparece en ejecución, en campo, y hace falta análisis estático, MISRA C, revisión por pares y suerte para atraparlo antes.

Rust seguro
use-after-freedoble freebuffer overflowpuntero nulocarrera ISR/mainalias mutableperiférico duplicadoerror de lógica
Comprobador de préstamos
Solo pasa el último. Los siete primeros son errores de compilación o pánicos controlados; el error de lógica sigue siendo trabajo del ingeniero.

Esa última fila importa y conviene decirla temprano para no vender humo: Rust no hace que su algoritmo de control sea correcto, ni que el filtro esté bien sintonizado, ni que el protocolo esté bien especificado. Elimina una familia concreta de fallos —la que domina las estadísticas de vulnerabilidades y los cuelgues difíciles— y deja intacto todo lo demás.

Las tres ideas que hay que entender

Rust tiene fama de difícil. En realidad tiene tres ideas centrales, y una vez que se entienden el resto del lenguaje se lee como cualquier otro.

Propiedad. Cada valor tiene exactamente un propietario. Cuando el propietario sale de ámbito, el valor se libera. No hay malloc y free que emparejar a mano ni recolector de basura que recorra el montón: la liberación es una decisión del compilador, tomada en tiempo de compilación, con coste cero en ejecución. Es el mismo principio del RAII de C++, pero obligatorio y verificado.

Préstamos. Se puede prestar una referencia a un valor sin transferir la propiedad, con una regla: o bien muchas referencias de solo lectura, o bien una sola de escritura, nunca las dos cosas a la vez. Esa regla, que parece una restricción arbitraria, es exactamente la que elimina las carreras de datos y los alias mutables que corrompen memoria.

Abstracciones de coste cero. Iteradores, genéricos, tipos envoltorio, Option y Result: todo eso se resuelve en tiempo de compilación por monomorfización y desaparece del binario. Un iterador de Rust compila a lo mismo que un bucle for escrito a mano en C. Se paga en tiempo de compilación, no en ciclos de reloj ni en bytes de flash.

Un microcontrolador no tiene sistema operativo: no_std

La biblioteca estándar de Rust asume un sistema operativo: hilos, archivos, sockets, un asignador de memoria dinámica. Nada de eso existe en un Cortex-M4 con 128 KB de flash. Por eso el firmware se escribe contra core, el subconjunto de la biblioteca estándar que no depende del entorno: tipos primitivos, Option, Result, iteradores, aritmética, rebanadas. Se activa con un atributo en la primera línea del programa.

Rustsrc/main.rs — el esqueleto mínimo de un firmware
#![no_std]   // sin biblioteca estándar: solo `core`
#![no_main]  // sin el arranque de un programa de escritorio

use cortex_m_rt::entry;
use panic_halt as _;   // qué hacer si el programa entra en pánico

#[entry]
fn main() -> ! {
    loop {
        // El tipo de retorno `!` significa "esta función nunca retorna".
        // El compilador rechaza un firmware que pueda caerse del final de main.
    }
}

Tres detalles que dicen mucho. El primero es -> !: el tipo «nunca», que obliga a que main sea un bucle infinito. En C, salir de main en un microcontrolador lleva a un bucle de reinicio en el arranque o a comportamiento indefinido, y el compilador no dice nada. El segundo es panic_halt: cuando algo va mal —un índice fuera de rango, una división por cero—, Rust entra en pánico, y en un sistema empotrado hay que decidir explícitamente qué significa eso. Se puede detener el núcleo, reiniciar por perro guardián o registrar el fallo antes de reiniciar; lo que no se puede es ignorarlo. El tercero es que la decisión se toma importando una crate, sin escribir código: el manejador de pánico es un punto de extensión del lenguaje.

El proyecto se completa con tres archivos de configuración. El objetivo de compilación cruzada, el guion de enlazado y el ejecutor que graba en la placa:

TOML.cargo/config.toml — objetivo, enlazado y ejecución en la placa
[build]
target = "thumbv7em-none-eabihf"   # Cortex-M4F, sin sistema operativo

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411RETx"
rustflags = ["-C", "link-arg=-Tlink.x"]
TOMLCargo.toml — dependencias y perfil de publicación
[dependencies]
cortex-m       = "0.7"
cortex-m-rt    = "0.7"
panic-halt     = "1.0"
stm32f4xx-hal  = { version = "0.22", features = ["stm32f411"] }

[profile.release]
opt-level     = "z"      # optimizar por tamaño
lto           = true     # optimización al enlazar, entre crates
codegen-units = 1        # una unidad: mejor optimización, compilación más lenta
panic         = "abort"  # sin desenrollado de pila: ahorra kilobytes
debug         = true     # símbolos para depurar; no ocupan flash

Con eso, cargo build --release produce un ELF listo para grabar. No hay makefile, no hay que instalar una cadena de herramientas del fabricante, no hay que resolver a mano las dependencias entre bibliotecas: rustup target add thumbv7em-none-eabihf y ya está. Para quien viene de configurar toolchains de GCC a mano, este solo punto ya justifica la mirada.

La pila del Rust embebido, capa a capa

La arquitectura del ecosistema es una de sus mejores decisiones de diseño, porque separa con claridad lo que en C suele venir mezclado en un SDK monolítico.

Ecosistema C
Capa
Ecosistema Rust
main.c y llamadas al RTOSLógica de la aplicación mezclada con la API del sistema
Aplicación
async fn main o #[rtic::app]Tareas declaradas; el marco decide cómo ejecutarlas
FreeRTOS, Zephyr o bucle desnudoHilos con pila propia, semáforos y colas
Concurrencia
Embassy, RTIC o bucle desnudoTareas async sin pila propia, o prioridades analizables
Un driver por fabricanteReescribir el driver del sensor al cambiar de chip
Drivers
Traits de embedded-hal 1.0El driver se escribe una vez y sirve en cualquier chip
STM32Cube, ESP-IDF, nRF SDKCapa de abstracción del fabricante, específica y extensa
HAL de chip
stm32f4xx-hal, embassy-stm32, esp-halImplementa los traits comunes con tipos propios del chip
Cabeceras y macros CMSISPunteros y máscaras de bits definidos con #define
Registros
PAC generado con svd2rustAcceso tipado a cada campo, generado del archivo SVD
La pila no cambia de forma: cambia de piezas. Por eso una migración puede hacerse capa a capa, conviviendo con C mediante FFI.

La capa que más rendimiento da es la de los traits de embedded-hal. La versión 1.0 se publicó el 9 de enero de 2024 y estabilizó los contratos de GPIO, SPI, I2C, UART y temporización. Antes de esa fecha cada driver se escribía contra una versión inestable de los traits y el ecosistema se rompía en cada actualización; después, un driver de sensor publicado en crates.io funciona sin cambios en un STM32, un nRF52 o un RP2350. Ese es un valor que el mundo C nunca ha tenido: en C, el driver de un acelerómetro viene atado al SDK del fabricante que lo escribió.

Propiedad aplicada al hardware: el periférico como recurso único

Aquí es donde el lenguaje empieza a devolver algo específico del dominio empotrado. Un periférico —un puerto GPIO, un temporizador, un bus SPI— es un recurso físico único. Si dos partes del programa lo configuran de forma distinta, el comportamiento resultante depende del orden de ejecución. En C, nada lo impide: cualquier archivo puede escribir en el registro.

En Rust, el conjunto de periféricos es un valor con propietario, y solo se puede tomar una vez:

RustEl patrón «singleton»: los periféricos se toman una sola vez
use stm32f4xx_hal::pac;

let dp = pac::Peripherals::take().unwrap();   // devuelve Some(...) la primera vez
let otra = pac::Peripherals::take();          // devuelve None: ya están tomados

// A partir de aquí, cada periférico se mueve a quien lo va a usar.
let gpioa = dp.GPIOA.split();   // `dp.GPIOA` deja de estar disponible
let spi   = dp.SPI1;            // `dp.SPI1` se mueve; nadie más puede usarlo

Conviene ser preciso: esta comprobación es en ejecución, no en compilación —take() devuelve un Option—, pero lo que sí es de compilación es lo que viene después. Una vez que dp.SPI1 se movió a una función, el compilador impide usarlo desde otro sitio. El acceso concurrente al periférico deja de ser una convención documentada en un comentario y pasa a ser una propiedad del tipo.

Typestate: el estado del pin viaja en el tipo

El patrón que más impresiona a quien viene de C se llama typestate. La idea es codificar el estado de configuración de un periférico en su tipo, de modo que las operaciones inválidas ni siquiera existan.

Pin<‘A’, 5, Input<Floating>>
.into_push_pull_output()
Pin<‘A’, 5, Output<PushPull>>

Pin<‘A’, 5, Output<PushPull>>
.set_high() / .toggle()
compila y enciende el LED

Pin<‘C’, 13, Input<PullUp>>
.set_high()
error[E0599]: no existe ese método

El pin no «sabe» que es una entrada: su tipo lo dice, y el método de salida no está definido para ese tipo. El error llega en el editor, no con el osciloscopio.

Escribir en el registro de salida de un pin configurado como entrada es un error clásico que en C se manifiesta como «el LED no enciende» y cuesta media tarde encontrar. En Rust el método set_high sencillamente no está implementado para el tipo Input, así que el programa no compila. Y el coste en ejecución de toda esta ceremonia es exactamente cero: los parámetros de tipo desaparecen en la compilación; el binario contiene una escritura a un registro, igual que en C.

El mismo parpadeo, en los dos lenguajes

Dos microcontroladores enfrentados: uno con el logotipo de Rust y la etiqueta Rust Embedded, otro con la letra C y la etiqueta C Embedded, separados por la palabra VS
Las dos formas de escribir el mismo firmware. Lo que cambia no es el tamaño del binario que sale, sino lo que el compilador acepta dejar salir.

Nada aclara tanto como poner el mismo programa al lado. Este es el clásico LED que parpadea en una Nucleo-F411RE, primero con la HAL de STMicroelectronics:

Cmain.c con STM32Cube HAL
#include "stm32f4xx_hal.h"

int main(void) {
    HAL_Init();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef cfg = {0};
    cfg.Pin   = GPIO_PIN_5;
    cfg.Mode  = GPIO_MODE_OUTPUT_PP;
    cfg.Pull  = GPIO_NOPULL;
    cfg.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &cfg);

    while (1) {
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
        HAL_Delay(500);
    }
}
Rustsrc/main.rs con stm32f4xx-hal
#![no_std]
#![no_main]

use cortex_m::asm;
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();

    // El reloj se configura una vez y queda «congelado»: el tipo `Clocks`
    // que devuelve `freeze()` es la prueba de que ya está fijado.
    let rcc = dp.RCC.constrain();
    let _clocks = rcc.cfgr.sysclk(84.MHz()).freeze();

    let gpioa = dp.GPIOA.split();
    let mut led = gpioa.pa5.into_push_pull_output();

    loop {
        led.toggle();
        asm::delay(42_000_000);   // ciclos de CPU, no milisegundos
    }
}

Las dos versiones ocupan lo mismo en pantalla y compilan a un tamaño del mismo orden. Lo que cambia no se ve a simple vista. En la versión en C, GPIOA es una macro que expande a un puntero constante: cualquier archivo del proyecto puede escribir en ese registro en cualquier momento, y si alguien deja el reloj del puerto sin habilitar, la escritura se pierde en silencio. En la versión en Rust, gpioa.pa5 es un valor con propietario, su tipo dice que es una salida push-pull, y el objeto Clocks que devuelve freeze() es la evidencia, verificada por el compilador, de que el árbol de relojes ya se configuró antes de que ningún periférico dependiente pueda usarse.

Un driver que sirve en cualquier chip

El argumento más práctico para adoptar Rust en un equipo de producto no es la seguridad de memoria: es la portabilidad de los drivers. Con embedded-hal, el driver de un sensor se escribe contra el trait del bus, no contra un chip:

RustDriver genérico de un sensor de temperatura por I2C
use embedded_hal::i2c::I2c;

pub struct Tmp102<I2C> {
    i2c: I2C,
    dir: u8,
}

impl<I2C: I2c> Tmp102<I2C> {
    pub fn new(i2c: I2C, dir: u8) -> Self {
        Self { i2c, dir }
    }

    /// Lee el registro 0x00 y lo convierte a grados Celsius.
    pub fn temperatura(&mut self) -> Result<f32, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.dir, &[0x00], &mut buf)?;
        let crudo = i16::from_be_bytes(buf) >> 4;   // 12 bits con signo
        Ok(f32::from(crudo) * 0.0625)
    }

    /// Devuelve el bus al llamador: el driver no se queda con el recurso.
    pub fn liberar(self) -> I2C {
        self.i2c
    }
}

Ese código no menciona ningún fabricante. Compila y funciona sobre stm32f4xx-hal, sobre esp-hal, sobre rp235x-hal y también sobre linux-embedded-hal, que implementa los mismos traits sobre /dev/i2c-1 de una Raspberry Pi: el mismo driver se puede probar en un PC antes de tener la placa. Fíjese además en dos detalles idiomáticos. El ? propaga el error del bus hacia arriba sin escribir un if, y el tipo Result obliga al llamador a decidir qué hace si la lectura falla; en C, ignorar el código de retorno de una función de I2C es tan fácil como no escribirlo. Y liberar(self) consume el driver y devuelve el bus, de modo que se puede compartir un mismo I2C entre varios sensores sin trucos.

Compartir datos con una interrupción sin romper nada

Este es, en mi experiencia, el error más caro del firmware en C y el mejor argumento técnico a favor de Rust en este dominio. Una interrupción incrementa un contador; el bucle principal lo lee. Parece trivial.

CEl patrón habitual, y su trampa
volatile uint32_t pulsos = 0;

void EXTI0_IRQHandler(void) {
    pulsos++;                       /* ¿es atómico? depende del núcleo */
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
}

int main(void) {
    /* ... */
    while (1) {
        __disable_irq();
        uint32_t copia = pulsos;    /* si olvidas la sección crítica, */
        __enable_irq();             /* nadie te avisa: compila igual   */
        procesar(copia);
    }
}

volatile le dice al compilador que no cachee la variable en un registro, pero no garantiza atomicidad: en un Cortex-M0 sin instrucciones de acceso exclusivo, pulsos++ son tres instrucciones y una interrupción puede colarse en medio. Si el contador fuera de 64 bits, el problema existiría hasta en un Cortex-M7. Y si alguien olvida __disable_irq() antes de leer, el programa compila sin una advertencia y falla una vez cada diez mil lecturas.

RustLa sección crítica es obligatoria porque el tipo la exige
use core::cell::RefCell;
use critical_section::Mutex;
use stm32f4xx_hal::pac::interrupt;

// Estado global compartido. No hay `static mut`: eso es `unsafe` en Rust.
static PULSOS: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

#[interrupt]
fn EXTI0() {
    critical_section::with(|cs| {
        *PULSOS.borrow_ref_mut(cs) += 1;
    });
}

fn leer_pulsos() -> u32 {
    // Sin el testigo `cs` no hay forma de llegar al valor:
    // el acceso sin sección crítica no se puede ni escribir.
    critical_section::with(|cs| *PULSOS.borrow_ref(cs))
}

La pieza clave es el parámetro cs. Es un testigo, un valor que solo puede existir dentro de critical_section::with, y los métodos de acceso al dato lo exigen como argumento. No es una convención ni una recomendación del manual de estilo: es la firma del método. Olvidar la sección crítica deja de ser un error posible y pasa a ser un programa que no compila. Ese es el patrón mental que conviene llevarse de todo el artículo: en Rust, las reglas de disciplina que en C viven en la revisión de código se codifican en los tipos.

Concurrencia moderna: RTIC y Embassy

Cuando el firmware crece, un bucle principal con banderas globales deja de escalar y aparece la pregunta del RTOS. El ecosistema Rust ofrece dos respuestas, y ninguna de las dos es «FreeRTOS pero en Rust».

RTIC (Real-Time Interrupt-driven Concurrency) no es un sistema operativo: es un marco que usa directamente el controlador de interrupciones del Cortex-M. Las tareas son manejadores de interrupción con prioridad, no hilos con pila propia; el acceso a recursos compartidos se resuelve con el protocolo de techo de prioridad inmediato, calculado en tiempo de compilación. El resultado es un sistema sin cambios de contexto, sin memoria dinámica y con un análisis de planificabilidad tratable —justamente lo que se necesita para argumentar tiempo real duro ante un auditor.

RustRTIC 2: tareas con prioridad y recursos compartidos declarados
#[rtic::app(device = stm32f4xx_hal::pac, peripherals = true)]
mod app {
    use stm32f4xx_hal::gpio::{Output, Pin, PushPull};

    #[shared]
    struct Shared { pulsos: u32 }

    #[local]
    struct Local { led: Pin<'A', 5, Output<PushPull>> }

    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        // ... configuración de relojes, GPIO e interrupciones ...
        (Shared { pulsos: 0 }, Local { led })
    }

    // Se dispara con la interrupción EXTI0, con prioridad 2.
    #[task(binds = EXTI0, shared = [pulsos], priority = 2)]
    fn contar(mut cx: contar::Context) {
        cx.shared.pulsos.lock(|p| *p += 1);
    }

    // Prioridad 1: puede ser interrumpida por la anterior.
    #[task(shared = [pulsos], priority = 1)]
    async fn informar(mut cx: informar::Context) {
        let n = cx.shared.pulsos.lock(|p| *p);
        defmt::info!("pulsos = {}", n);
    }
}

Embassy toma el camino contrario: usa async/await del lenguaje con un ejecutor pensado para microcontroladores. Cada tarea es una máquina de estados generada por el compilador, con su tamaño calculado en compilación y reservada estáticamente; no hay pila por tarea ni montón. Cuando todas las tareas están esperando, el ejecutor pone el núcleo a dormir: el ahorro de energía sale gratis del modelo, sin escribir la máquina de estados de bajo consumo a mano.

RustEmbassy: dos tareas concurrentes sin RTOS y sin memoria dinámica
#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::{Duration, Timer};
use panic_probe as _;

#[embassy_executor::task]
async fn parpadeo(mut led: Output<'static>) {
    loop {
        led.toggle();
        Timer::after(Duration::from_millis(500)).await;
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let led = Output::new(p.PA5, Level::Low, Speed::Low);
    spawner.spawn(parpadeo(led)).unwrap();

    loop {
        // Otra tarea, concurrente con la anterior. Mientras ambas esperan,
        // el núcleo entra en bajo consumo sin que haya que pedirlo.
        Timer::after(Duration::from_secs(1)).await;
        defmt::info!("sigo vivo");
    }
}

La comparación de huella es reveladora. En una medición publicada sobre una Nucleo-F401RE, un mismo programa ocupaba unos 48,7 KB de flash y 13,8 KB de RAM estática con Zephyr, unos 18,8 KB y 9,4 KB con Zephyr recortado al mínimo, y unos 13,2 KB de flash con 1,7 KB de RAM estática con Embassy. La diferencia no viene de que Rust genere código más denso —no lo hace—, sino del modelo: no hay pilas por hilo que dimensionar ni estructuras del núcleo del RTOS que residan en RAM.

Como regla de decisión: Embassy es hoy el punto de partida razonable para un producto nuevo con periféricos y esperas; RTIC es la opción cuando hace falta argumentar plazos y prioridades con un análisis formal. Si viene del mundo FreeRTOS, el artículo sobre ESP32 y FreeRTOS le sirve de contraste directo: las mismas tareas, otro modelo de ejecución.

Las herramientas: cargo, probe-rs y defmt

Un punto que se subestima al comparar lenguajes es cuánto tiempo se pierde en la periferia del trabajo. En Rust embebido, la cadena completa se instala con dos órdenes y se usa siempre igual, sea cual sea el fabricante.

ShellDe cero a firmware corriendo en la placa
# objetivo de compilación cruzada y herramientas
rustup target add thumbv7em-none-eabihf
cargo install probe-rs-tools cargo-binutils
rustup component add llvm-tools

# compilar, grabar y ejecutar; los logs vuelven por la sonda
cargo build --release
cargo run   --release          # usa el `runner` del config.toml

# tamaño real por secciones
cargo size --release -- -A

probe-rs reemplaza a OpenOCD y a la pareja GDB más servidor: detecta la sonda, graba, reinicia y ejecuta con una sola orden, y funciona con ST-Link, J-Link y CMSIS-DAP. defmt resuelve el viejo problema del printf en un microcontrolador: en lugar de formatear la cadena en el dispositivo y gastar flash en el texto, envía por RTT un identificador y los argumentos en binario, y el anfitrión reconstruye el mensaje usando la tabla de símbolos del ELF. El coste en flash de una línea de registro cae a unos pocos bytes, lo que permite dejar la instrumentación puesta en producción.

Rustdefmt: registro con tipos, casi sin coste
defmt::info!("temperatura = {=f32} °C, pulsos = {=u32}", t, n);
defmt::warn!("umbral superado en {=u16} ms", retardo);

// La cadena "temperatura = ..." no viaja al dispositivo: se queda
// en la tabla de símbolos del anfitrión. Por el cable van 8 bytes.

La salida de cargo size es el instrumento que cierra la discusión sobre huella. Se lee así —los números son ilustrativos y dependen por completo del proyecto—:

Salidacargo size –release — -A, sección por sección
section              size        addr
.vector_table         392   0x8000000   -> tabla de vectores, en flash
.text                4128   0x8000188   -> código, en flash
.rodata               248   0x80011a8   -> constantes, en flash
.data                   4  0x20000000   -> globales inicializadas, en RAM
.bss                   16  0x20000004   -> globales a cero, en RAM
.uninit                 0  0x20000014

¿Y el rendimiento y el tamaño?

La respuesta corta: comparables a C, con dos matices que conviene conocer para no llevarse sorpresas.

El primero es la comprobación de límites. Indexar una rebanada con datos[i] inserta una comprobación de rango que puede provocar un pánico. En la mayoría de los casos LLVM la elimina porque puede demostrar que el índice es válido, sobre todo si se usan iteradores en lugar de índices. Cuando no puede, hay alternativas idiomáticas: for x in datos.iter(), datos.get(i) devolviendo Option, o arreglos de tamaño fijo [u8; 64] cuyo tamaño el compilador conoce. La regla práctica es escribir con iteradores; el código sale más corto y más rápido a la vez.

El segundo es el manejo de pánico. El desenrollado de pila añade kilobytes de código de limpieza que en un microcontrolador no sirven de nada; por eso panic = "abort" es prácticamente obligatorio en el perfil de publicación. Con eso, más opt-level = "z", lto = true y codegen-units = 1, la huella de un firmware equivalente en Rust y en C queda en el mismo orden de magnitud, y los formateadores de core::fmt —que son grandes— desaparecen si se usa defmt en lugar de write!.

unsafe: dónde sigue haciendo falta

Rust no elimina las operaciones peligrosas; las confina. El bloque unsafe habilita cinco cosas que el compilador no puede verificar, entre ellas desreferenciar un puntero crudo y llamar a funciones externas. En firmware, eso es exactamente lo que hay en el fondo de la pila: escribir en una dirección de memoria mapeada a un registro.

RustLo que hay debajo de todas las capas
const GPIOA_ODR: *mut u32 = 0x4002_0014 as *mut u32;

/// Enciende PA5 escribiendo directamente en el registro de salida.
///
/// # Seguridad
/// El llamador debe garantizar que el reloj de GPIOA está habilitado
/// y que ninguna otra parte del programa escribe en este registro.
pub fn encender_pa5() {
    unsafe { core::ptr::write_volatile(GPIOA_ODR, 1 << 5) };
}

La diferencia con C no es que este código no exista, sino dónde vive. En un proyecto bien estructurado, todo el unsafe queda dentro de la PAC y de la HAL —código generado o revisado por muchos ojos—, y la aplicación no escribe ni un bloque. Cuando aparece uno en el código de aplicación, es visible en la revisión, se puede buscar con grep, se puede prohibir con #![forbid(unsafe_code)] a nivel de crate y la convención del ecosistema obliga a documentar la invariante con una sección # Safety. En C, en cambio, todo el programa es unsafe y no hay forma de marcar la diferencia.

Convivir con C: nadie reescribe medio millón de líneas

La adopción realista no es una reescritura. Es introducir Rust en un módulo nuevo —un analizador de protocolo, una máquina de estados, un componente de red, la parte que procesa entradas no confiables— y dejar el resto donde está. Rust habla el ABI de C de forma nativa en las dos direcciones.

RustFFI en los dos sentidos (sintaxis de la edición 2024)
// 1) Llamar a una función del SDK del fabricante, escrito en C.
unsafe extern "C" {
    fn sdk_leer_bateria_mv() -> u16;
}

pub fn bateria_mv() -> u16 {
    // El `unsafe` se encapsula aquí; el resto del programa ve una
    // función normal y segura.
    unsafe { sdk_leer_bateria_mv() }
}

// 2) Exponer una función Rust para que la llame el código C existente.
#[unsafe(no_mangle)]
pub extern "C" fn media_movil(datos: *const u16, n: usize) -> u16 {
    if datos.is_null() || n == 0 {
        return 0;
    }
    let muestras = unsafe { core::slice::from_raw_parts(datos, n) };
    let suma: u32 = muestras.iter().map(|&x| u32::from(x)).sum();
    (suma / n as u32) as u16
}

Para no escribir las declaraciones a mano, bindgen genera los enlaces de Rust a partir de las cabeceras C del fabricante, y cbindgen hace el camino inverso, generando la cabecera .h a partir del código Rust. Es el mismo patrón con el que Espressif integró Rust sobre ESP-IDF antes de tener un HAL nativo.

Seguridad funcional: el punto que decide en industria

Durante años, el argumento definitivo contra Rust en sectores regulados fue que no había un compilador cualificado. Eso dejó de ser cierto. Ferrocene, la distribución de Ferrous Systems, está cualificada por TÜV SÜD para desarrollo relacionado con la seguridad según ISO 26262 hasta ASIL D, IEC 61508 hasta SIL 3 e IEC 62304 clase C, y avanza hacia SIL 4 y DO-178C.

El movimiento más relevante es más reciente y va más allá del compilador: en diciembre de 2025 Ferrous Systems obtuvo la certificación IEC 61508 SIL 2 para un subconjunto de la biblioteca core, y la versión 26.02.0 de Ferrocene añadió ISO 26262 ASIL B para ese subconjunto, elevando el número de funciones certificadas de 2 903 a 5 169. Eso cambia la ecuación práctica: hasta ahora, quien quería enviar Rust certificable tenía que rodear las piezas básicas del lenguaje; ahora puede usarlas.

Compilador
ISO 26262 ASIL D
Ferrocene, cualificado por TÜV SÜD, también para IEC 61508 SIL 3 e IEC 62304 clase C. Hay además una segunda oferta comercial en el mercado desde 2024.

Biblioteca core
5 169 funciones
Subconjunto certificado IEC 61508 SIL 2 e ISO 26262 ASIL B en Ferrocene 26.02.0, frente a 2 903 en la entrega anterior.

Industria
De 0,3 % a 5,3 %
Proporción de organizaciones del sector automotriz que declaran usar Rust entre 2024 y 2025. Volvo lleva software de unidades de control en Rust y RTIC en el XC90 y el Polestar 3; Renault ha anunciado uso en vehículos de 2026.

En paralelo, el Safety-Critical Rust Consortium, creado en junio de 2024 bajo la Rust Foundation, trabaja en unas guías de codificación equivalentes a lo que MISRA C es para C, y ya existen trabajos académicos que mapean las reglas de MISRA C++ al lenguaje. Es exactamente el tipo de infraestructura que un auditor pide y que hace cinco años no existía.

Conviene, eso sí, no exagerar. Un ingeniero de robótica citado por el proyecto Rust resumía la ganancia diciendo que «el 90 % de lo que la verificación externa tenía que revisar lo hace ahora el compilador»; pero el mismo material señala que en componentes de criticidad alta los equipos evitan depender de crates de terceros y las reescriben internamente, y que siguen faltando piezas: generación de código desde Simulink, un RTOS compatible con AUTOSAR escrito en Rust y requisitos claros para ejecutores async cualificables.

Lo que todavía duele

Un análisis que solo enumere ventajas no sirve para decidir. Estas son las limitaciones reales, hoy:

Inalámbrico. Es el hueco más grande. La pila BLE nativa de Embassy funciona, pero sin las cualificaciones que traen las pilas C de los fabricantes; Thread y 802.15.4 suelen resolverse envolviendo OpenThread en C, y Matter avanza atado a ese mismo ritmo.
MCU de 8 y 16 bits. AVR y MSP430 siguen siendo objetivos de nivel 3, con compilador nocturno y un backend de LLVM históricamente menos maduro que el de ARM. Para un ATmega o un MSP430, C sigue siendo la respuesta correcta.
SDK de fabricante. La documentación, las notas de aplicación y el soporte oficial siguen escritos para C. Espressif es la excepción notable: publicó esp-hal 1.0 en octubre de 2025 y lo trata como SDK de primera clase.
Tiempos de compilación. Una compilación limpia con LTO y una sola unidad de generación es notablemente más lenta que la de un proyecto C equivalente. Las incrementales son razonables, pero el primer cargo build del día se nota.
Curva de aprendizaje. Las dos primeras semanas se pelea con el comprobador de préstamos. La dificultad real no es la sintaxis: es que Rust obliga a decidir por adelantado cuestiones de propiedad y duración que en C se dejaban implícitas.
Talento. Contratar un ingeniero de firmware con experiencia en Rust es hoy más difícil y más caro que contratar uno de C. En equipos pequeños es un riesgo de continuidad que hay que ponderar de forma explícita.
Cadena de modelado. No hay generación de código Simulink hacia Rust ni un RTOS compatible con AUTOSAR Classic escrito en Rust. En automoción eso empuja hacia arquitecturas mixtas, no hacia una sustitución.
Dependencias. La facilidad de cargo add es una ventaja en prototipo y un problema en criticidad alta, donde cada crate arrastra una carga de verificación. Los equipos maduros fijan versiones, auditan y sacan dependencias de las rutas críticas.

Cuándo elegir Rust y cuándo quedarse en C

Elija Rust si…
El objetivo es Cortex-M o RISC-V con periféricos cableados: I2C, SPI, UART, ADC, temporizadores, USB.
El firmware procesa entradas que no controla —tramas de red, comandos por serie, archivos— donde el desbordamiento de búfer es el riesgo principal.
El producto va a vivir años en campo y el coste de un cuelgue reproducible una vez al mes es alto.
El equipo mantiene drivers para varias familias de chips y la portabilidad de embedded-hal ahorra trabajo real.
Hay que argumentar seguridad funcional y conviene que el compilador se lleve parte de la carga de verificación.
Es un módulo nuevo dentro de un proyecto en C: se integra por FFI sin tocar lo demás.

Quédese en C si…
El objetivo es un AVR de 8 bits, un MSP430 o un núcleo propietario sin soporte en LLVM.
El producto depende de una pila BLE, Thread o Matter cualificada por el fabricante y no hay margen para asumir el riesgo.
Existe una base madura en C que funciona, sin defectos abiertos de memoria, y no hay presupuesto para una migración.
El flujo de trabajo depende de generación de código desde Simulink o de un RTOS certificado que no tiene equivalente.
El equipo es de una o dos personas y no hay margen para dos meses de curva de aprendizaje.
El proyecto es una prueba de concepto de dos semanas sobre una placa de evaluación con ejemplos en C listos.

Por dónde empezar

1
Elija una placa con buen soporte
Nucleo o Discovery de STM32, nRF52840-DK, Raspberry Pi Pico 2 o una ESP32-C3. Las cuatro tienen HAL mantenido, ejemplos de Embassy y sonda de depuración integrada o barata.
Hardware

2
Monte la cadena en diez minutos
rustup target add del objetivo, cargo install probe-rs-tools y una plantilla de cargo-generate. Encienda un LED antes de leer nada más: ver el ciclo compilar-grabar-registrar funcionando cambia la percepción del lenguaje.
Herramientas

3
Trabaje los dos libros de referencia
The Embedded Rust Book para los conceptos —PAC, HAL, typestate, concurrencia— y Discovery para el recorrido guiado con una placa concreta. Los dos son del grupo de trabajo oficial y están al día.
Fundamentos

4
Porte un driver que ya conozca
Tome un sensor que ya haya manejado en C y reescriba su driver contra los traits de embedded-hal. Es el ejercicio que mejor enseña propiedad, préstamos y manejo de errores, porque el dominio ya lo domina.
Práctica

5
Introdúzcalo en producción por un módulo
Elija un componente nuevo y no crítico que procese entradas externas, expóngalo por FFI y déjelo convivir con el firmware en C. Es la ruta que han seguido los fabricantes que hoy tienen Rust en la calle.
Adopción

Conclusión

Rust no es una alternativa a C en el sentido de reemplazarlo mañana. Es una alternativa en el sentido preciso de que, para una parte creciente de proyectos empotrados, ya no hay una razón técnica sólida para descartarlo: el compilador está cualificado hasta ASIL D, los traits de los drivers son estables desde 2024, hay dos marcos de concurrencia maduros, las herramientas son mejores que las del mundo C, la huella es comparable y hay vehículos de serie en la calle con firmware en Rust.

Lo que queda por resolver está bien delimitado y es honesto reconocerlo: las pilas inalámbricas cualificadas, los microcontroladores de 8 y 16 bits, la cadena de modelado en automoción y la disponibilidad de gente formada. Si su proyecto vive en un Cortex-M con periféricos cableados y su preocupación es que el firmware siga funcionando dentro de cinco años sin cuelgues que nadie sabe reproducir, la pregunta razonable ya no es si Rust está listo, sino qué módulo del próximo proyecto va a escribir con él.

Esquema de Rust en sistemas embebidos: comparación capa a capa de la pila C y la pila Rust, tabla de clases de fallo que detiene el compilador, cadena de herramientas cargo, probe-rs, defmt y cargo size, y madurez del ecosistema por frente
Rust embebido en una página: las dos pilas capa a capa, qué familia de fallos detiene el compilador, la cadena de herramientas y en qué frentes el ecosistema está maduro y en cuáles todavía no.

¿Te resultó útil este análisis o buscas asesoría en la materia?

Acompaño a equipos de producto en la evaluación de Rust para firmware, el diseño de la arquitectura de la pila, la portabilidad de drivers con embedded-hal y la integración con bases de código en C existentes. Conversemos sobre tu caso.

 Solicitar consultoría

Fuentes

  • Rust Embedded Working Group, anuncio de embedded-hal v1.0 (9 de enero de 2024).
  • awesome-embedded-rust, índice de crates, HAL y herramientas del grupo de trabajo.
  • Derek Molloy, «The State of Rust for Embedded Development in Mid-2026», incluida la comparación de huella entre Zephyr y Embassy en la Nucleo-F401RE.
  • Espressif, anuncio de esp-hal 1.0.0 (octubre de 2025).
  • Rust Blog, «What does it take to ship Rust in safety-critical?» (14 de enero de 2026).
  • Ferrous Systems, certificación IEC 61508 SIL 2 del subconjunto de core (diciembre de 2025) y Ferrocene 26.02.0, con ISO 26262 ASIL B y 5 169 funciones certificadas.
  • Safety-Critical Rust Coding Guidelines y el Safety-Critical Rust Consortium de la Rust Foundation.
  • Tweede golf, sobre el uso de Rust y RTIC en unidades de control de Volvo.
  • CISA, NSA y otras agencias, «The Case for Memory Safe Roadmaps» (diciembre de 2023), con las proporciones de vulnerabilidades de memoria en Microsoft y Chromium.
  • Rust 1.85.0, que estabiliza la edición 2024 y la sintaxis unsafe extern usada en los ejemplos de FFI.
  • Documentación de referencia: The Embedded Rust Book, Discovery y docs.rs para la API exacta de cada versión de HAL.

Los fragmentos de código son ilustrativos y están escritos contra las versiones vigentes a setiembre de 2026 de embedded-hal, cortex-m-rt, stm32f4xx-hal, RTIC y Embassy. Los nombres de método y las firmas cambian entre versiones mayores de cada HAL: antes de copiarlos a un proyecto, contraste con la documentación de la versión concreta que tenga en su Cargo.toml.

Compartir:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *