Un programa bare-metal: no_std, no_main y el LED que parpadea
El hola mundo del embebido desnuda lo que la biblioteca estándar escondía: un runtime. `#![no_std]` renuncia a todo lo que depende de un sistema operativo y deja solo `core`; `#![no_main]` desecha el arranque que espera un runtime de C; y `cortex-m-rt` provee el esqueleto real —vector de reset, inicialización de memoria, tabla de vectores— antes de saltar a tu `#[entry]`, que nunca retorna porque no hay adónde volver.
Todo programa Rust que has escrito hasta ahora se apoyaba, sin que lo vieras, en un runtime: un trozo de código que se ejecuta antes de tu main, monta la pila, procesa los argumentos, prepara el asignador y, al terminar, recoge la mesa. Ese runtime lo prestaba el sistema operativo a través de la biblioteca estándar. En un microcontrolador no hay sistema operativo que lo preste, así que hay que renunciar a él y reconstruir el esqueleto a mano. Tres piezas lo consiguen: #![no_std] amputa todo lo que depende de un SO y deja solo el núcleo del lenguaje; #![no_main] descarta el punto de entrada convencional, que presupone un arranque de C; y el crate cortex-m-rt aporta el verdadero comienzo —el vector de reset, la inicialización de la RAM, la tabla de vectores— y cede el control a tu función #[entry], que no retorna nunca porque en el metal desnudo no existe un “después”. Esta lección construye ese hola mundo: un LED que parpadea, la prueba de vida del embebido.
- Entender qué renuncia
#![no_std]y qué conserva decore. - Comprender por qué
#![no_main]es necesario sin un runtime de C debajo. - Ver qué hace
cortex-m-rtentre el reset y tu#[entry], y por qué este devuelve-> !. - Escribir, configurar y flashear un programa que parpadea un LED.
no_std: quedarse con el lenguaje, soltar el sistema operativo
La biblioteca estándar de Rust se divide, sin que suela notarse, en dos mitades. core es el corazón independiente del sistema: tipos primitivos, Option, Result, iteradores, traits, slice, aritmética. No presupone nada del entorno. std envuelve a core y le añade todo lo que exige un sistema operativo debajo: Vec y String (que necesitan un asignador de heap), hilos, ficheros, red, tiempo del sistema. En un MCU no hay nada de eso, así que arrastrar std sería pedir servicios que no existen.
#![no_std]
Ese atributo en la raíz del crate desconecta std y te deja programando contra core. Sigues teniendo el lenguaje entero —el borrow checker, los traits, los genéricos, los enums, Result— porque todo eso vive en core. Lo que pierdes es la colección con heap y el acceso al SO. Si necesitas asignación dinámica sin SO, existe una vía intermedia: el crate alloc (con Vec y Box) sobre un asignador global que tú proveas. Pero el embebido idiomático prefiere, casi siempre, no tener heap en absoluto y reservarlo todo de forma estática.
Es un error frecuente creer que no_std es “Rust reducido”. No lo es: es Rust completo menos la parte de la biblioteca que solo tiene sentido con un SO. El sistema de tipos, la seguridad de memoria, los traits, la inferencia, todo sigue idéntico. Miles de crates —heapless, nb, fixed, los propios HAL— viven felices en no_std. Estás soltando el sistema operativo, no el lenguaje.
no_main y cortex-m-rt: reconstruir el arranque
Un fn main normal no es el primer código que corre: antes se ejecuta un runtime de C (el crt0) que el sistema operativo enlaza, encargado de montar el entorno y luego llamar a tu main. Sin SO, ese runtime no está. Por eso #![no_main] le dice al compilador que no espere la firma de main habitual: el punto de entrada lo definiremos nosotros.
Ahí entra cortex-m-rt, el runtime mínimo para los núcleos Cortex-M. Cuando el chip arranca, la CPU lee el vector de reset —una dirección grabada al principio de la flash— y salta a él. cortex-m-rt pone ahí su rutina de arranque, que hace el trabajo imprescindible antes de que tu código tenga sentido: copia los datos inicializados de la flash a la RAM (la sección .data), pone a cero las variables sin inicializar (la sección .bss), instala la tabla de vectores de interrupción y, solo entonces, invoca la función que hayas marcado con #[entry].
flowchart TD RESET[La CPU lee el vector de reset] --> RT[cortex-m-rt arranca] RT --> DATA[Copia .data de la flash a la RAM] RT --> BSS[Pone a cero la seccion .bss] DATA --> VT[Instala la tabla de vectores] BSS --> VT VT --> ENTRY[Salta a tu funcion con entry] ENTRY --> LOOP[Bucle infinito, no retorna nunca] PANIC[panic-halt provee el panic handler obligatorio] -.-> ENTRY style RESET fill:#fab387,color:#11111b style ENTRY fill:#89b4fa,color:#11111b style LOOP fill:#a6e3a1,color:#11111b
De aquí sale una firma que sorprende al recién llegado: la función de entrada devuelve -> !, el tipo never. No es un capricho: en un PC, cuando main retorna, el runtime recoge y devuelve el control al SO. En el metal desnudo no hay SO ni runtime que recoja; no existe un “después de main”. Si tu función terminara, la CPU seguiría ejecutando lo que hubiera en la siguiente dirección de memoria: basura. Por eso el tipo obliga a que nunca acabe, y de ahí el bucle infinito que corona todo programa embebido.
Falta una pieza que std regalaba: el manejador de pánico. Cuando algo entra en pánico, Rust invoca la función marcada con #[panic_handler]; std traía una que imprime y aborta, pero en no_std debes proveerla tú. La forma habitual es importar un crate que la define:
use panic_halt as _; // aporta un panic_handler que detiene la CPU en un bucle
El as _ es revelador: importas el crate solo por su efecto de borde —registrar el símbolo del manejador—, sin usar ningún nombre suyo. En depuración se prefiere panic-probe, que imprime el mensaje por defmt antes de detenerse.
El LED que parpadea, entero
Con esas piezas, el hola mundo se escribe así. Toma los periféricos, configura el reloj, saca el pin del LED, obtén un temporizador para el retardo y entra en el bucle eterno:
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{pac, prelude::*};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(84.MHz()).freeze(); // configura y congela relojes
let gpioc = dp.GPIOC.split();
let mut led = gpioc.pc13.into_push_pull_output();
let mut delay = dp.TIM2.delay_ms(&clocks);
loop {
led.set_high();
delay.delay_ms(500_u32);
led.set_low();
delay.delay_ms(500_u32);
}
}
El proyecto necesita tres ficheros más. Las dependencias en Cargo.toml:
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
panic-halt = "1"
stm32f4xx-hal = { version = "0.22", features = ["stm32f411"] }
La orden de compilación cruzada y el flasheador en .cargo/config.toml, que fija el target del núcleo (aquí un Cortex-M4F con FPU) y delega la ejecución a probe-rs:
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411CEUx"
Y el mapa de memoria en memory.x, que cortex-m-rt usa para colocar las secciones donde el silicio las espera:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}
Con todo en su sitio, cargo run --release compila para el target ARM, y probe-rs graba el binario en la flash por el depurador de la placa y lo ejecuta. Si añades defmt, sus mensajes de log llegan por el mismo cable, sin necesidad de un puerto serie aparte. El LED parpadea: tienes vida en el metal.
Muchos bucles de retardo y ajustes de reloj asumen optimización. Compilar en modo depuración deja código tan lento que los tiempos se descuadran y a veces la pila desborda la escasa RAM. En embebido es norma compilar y flashear siempre en --release, o al menos con opt-level elevado en el perfil de depuración. El binario final vive en kilobytes: cada optimización cuenta.
Vale la pena detenerse en lo que este hola mundo revela, porque cambia tu idea de qué es la biblioteca estándar. Durante todo el aprendizaje, std fue un fondo invisible: Vec, hilos, ficheros, un main que empieza y acaba con naturalidad. Parecía el lenguaje. No lo era. Era una capa —honesta, delgada y desmontable— que un sistema operativo sostenía por debajo, y #![no_std] la retira de un tajo para mostrar que debajo seguía estando el lenguaje entero, intacto. Esa separación limpia entre core (el idioma) y std (los servicios del SO) es una decisión de diseño que pocos lenguajes de su potencia pueden permitirse: la mayoría funden ambos en un runtime inseparable que no cabe en un chip. Rust no. Al quitar std no pierdes el borrow checker, ni los traits, ni una sola garantía de memoria; solo pierdes lo que dependía de un SO que aquí no existe, y lo reconstruyes explícitamente con cortex-m-rt. Y en esa reconstrucción el lenguaje te dice verdades que en el PC quedaban ocultas: que había un runtime antes de tu main, que alguien tenía que poner a cero la .bss, que “el programa termina” era una comodidad prestada por el SO y no una ley. El -> ! de la entrada es esa honestidad hecha tipo: en el metal no hay adónde volver, y el compilador te obliga a admitirlo. Programar bare-metal en Rust no es hacer “menos Rust”; es ver por primera vez, sin la niebla del sistema operativo, el mismo esqueleto que sostuvo siempre cada programa que escribiste.
#![no_std] desconecta la biblioteca estándar y te deja core —el lenguaje completo sin lo que depende de un SO—; para heap sin SO existe alloc, aunque el embebido suele evitarlo. #![no_main] descarta el punto de entrada convencional porque no hay runtime de C debajo. cortex-m-rt provee el arranque real desde el vector de reset: copia .data, pone a cero .bss, instala la tabla de vectores y salta a tu #[entry], que devuelve -> ! porque no hay un “después de main”. Debes aportar un #[panic_handler] (vía panic-halt o panic-probe). El proyecto se completa con Cargo.toml, un .cargo/config.toml que fija el target y el runner probe-rs, y un memory.x con el mapa de memoria. Siempre en --release.
- Explica con tus palabras qué contiene
corey qué añadestd, y por qué solo el primero cabe en un MCU. Nombra tres cosas destdque desaparecen con#![no_std]. - Razona por qué la función
#[entry]devuelve-> !y qué ocurriría físicamente si terminara. Relaciónalo con la ausencia de un runtime que recoja trasmain. - Enumera, en orden, los pasos que
cortex-m-rtejecuta entre el vector de reset y tu#[entry]. Explica por qué poner a cero la.bsses imprescindible. - Escribe el
usedel manejador de pánico conas _y explica por qué se importa por su efecto de borde. Contrastapanic-haltconpanic-probe. - Monta el proyecto de parpadeo completo (los cuatro ficheros) para una placa que tengas o simules, flashéalo con
probe-rsen--releasey cambia el periodo. Explica qué papel juega cada fichero.