wandres.dev
NO_STD · Rust sin librería estándar

Los mundos de no_std: un mismo core, muchos destinos

`no_std` no es un lugar, sino una familia de ellos. El mismo `core` sin dependencias late en el corazón de un microcontrolador de dos dólares, del núcleo de Linux, de un módulo WebAssembly y de un firmware UEFI. Cuatro mundos radicalmente distintos, un solo corazón portable; cada destino reañade lo que su entorno puede ofrecer. Por qué las librerías se escriben `no_std` y qué abre el nivel 42.

⏱ 18 min

no_std no es un sitio: es una familia de sitios. El mismo core sin dependencias —el corazón que define qué es un Iterator, cómo funciona ?, qué garantiza el borrow checker— late idéntico en un microcontrolador de dos dólares, en el núcleo de Linux, en un módulo WebAssembly y en el firmware que arranca tu portátil antes de que exista sistema operativo. Cuatro mundos que no podrían ser más distintos, y un solo corazón compartido entre todos. Lo que cambia de uno a otro no es core, sino lo que cada entorno reañade a su alrededor: aquí un asignador diminuto, allá las primitivas del núcleo, más allá las importaciones del anfitrión. Esta lección cartografía el territorio que core desbloquea, y señala hacia el nivel 42, donde por fin habitarás uno de estos mundos de verdad.

🎯 Al terminar esta lección sabrás
  • Reconocer no_std como una familia de destinos, no como un único entorno.
  • Situar los cuatro mundos: embebido, el núcleo, WebAssembly y firmware.
  • Entender por qué escribir librerías no_std maximiza su reutilización en todos ellos.
  • Ver el hilo común: un mismo core, y cada destino reañade lo que puede ofrecer.

Cuatro mundos, un mismo core

Cada uno de estos destinos parte de core y construye hacia arriba lo que su hardware o su capa anfitriona permiten:

🔌

Embebido y bare-metal

Microcontroladores sin SO: el ecosistema embedded-hal, cortex-m, RISC-V. Aborto al entrar en pánico, entrada propia, a veces sin alloc. Es el nivel 42.

🐧

El núcleo (Rust for Linux)

El núcleo de Linux es no_std por naturaleza: no hay libc ni espacio de usuario. Desde Linux 6.1 (2022) Rust es lenguaje admitido; la crate kernel expone un subconjunto seguro y curado.

🕸️

WebAssembly

wasm32-unknown-unknown no tiene SO debajo. Para módulos mínimos, plugins y el modelo de componentes se compila no_std, reduciendo tamaño y dependencias al hueso.

⚙️

Firmware y arranque

UEFI (x86_64-unknown-uefi y la crate uefi), gestores de arranque y firmware de dispositivo: código que corre antes o por debajo de cualquier sistema operativo.

Son escenarios sin nada en común a primera vista: un chip con dos kilobytes de RAM, el corazón de un sistema operativo de millones de líneas, una caja de arena dentro de un navegador, el código que enciende una placa base. Y sin embargo, los cuatro compilan el mismo Iterator, el mismo Result, la misma disciplina de ownership, porque todos parten del mismo core.

Cada mundo, eso sí, matiza la regla a su manera. En WebAssembly, std compila en parte, pero deja muchas operaciones como cabos que fallan en tiempo de ejecución, así que para módulos serios se opta por no_std a conciencia. En el embebido más ajustado no hay ni siquiera alloc: se programa sobre core puro, con estructuras de capacidad fija. El núcleo de Linux trae su propio asignador, sus propias primitivas de sincronización y su propia noción de fallo, todo reconstruido puertas adentro. Y el firmware UEFI se apoya en los boot services que define la especificación. Cuatro entornos, cuatro anillos distintos alrededor del mismo centro.

Por qué las librerías se escriben no_std

Aquí está la consecuencia práctica que multiplica el valor de todo lo anterior. Los autores de librerías escriben no_std —con alloc y std como features opcionales de Cargo— precisamente para que su crate corra en todos esos mundos y también en un programa std corriente. Una crate no_std es compatible con el superconjunto de destinos: funciona en cualquier parte. Por eso las piezas más reutilizadas del ecosistema lo son: serde en su núcleo, hashbrown, heapless, embedded-hal. El patrón en el manifiesto y en la raíz es este:

[features]
default = ["std"]
std = ["alloc"]
alloc = []
#![cfg_attr(not(feature = "std"), no_std)]

#[cfg(feature = "alloc")]
extern crate alloc;

El mismo código fuente, sin duplicarse, se compila como un core esbelto para un microcontrolador y como una crate std de plenas prestaciones en un servidor, según qué features actives. Escribir no_std no es programar para un nicho: es programar para la unión de todos los destinos a la vez.

El gateado desciende hasta el nivel de cada elemento. Una función que necesita el montículo se marca para existir solo cuando alloc está activa, y el núcleo del crate compila igual sin ella:

#[cfg(feature = "alloc")]
use alloc::vec::Vec;

/// Solo existe si hay asignador; el nucleo del crate no la necesita.
#[cfg(feature = "alloc")]
pub fn recolectar<I: Iterator<Item = u8>>(it: I) -> Vec<u8> {
    it.collect()
}

/// Esta version sin heap funciona en TODOS los destinos, hasta el mas humilde.
pub fn sumar<I: Iterator<Item = u8>>(it: I) -> u32 {
    it.map(u32::from).sum()
}

Así una sola base de código ofrece un núcleo universal y capacidades adicionales que se encienden cuando el entorno las permite. El autor no elige un destino: los cubre todos, y deja que cada consumidor active lo que su plataforma sostiene.

El ecosistema lo demuestra con nombres que quizá ya uses sin saberlo: serde serializa igual en un servidor que en un sensor, heapless da colecciones de capacidad fija al embebido, hashbrown es el HashMap que std esconde por dentro, defmt registra trazas en microcontroladores donde println! no existe. Todos son no_std, y por eso todos cruzan sin esfuerzo de un mundo a otro. En WebAssembly, además, reaparece el mismo mecanismo de punto de entrada de la lección anterior: exportas funciones que el anfitrión llamará, sin runtime de Rust de por medio.

#![no_std]

/// Un modulo wasm no_std: el anfitrion (navegador o wasmtime) llama aqui.
#[unsafe(no_mangle)]
pub extern "C" fn sumar(a: i32, b: i32) -> i32 {
    a + b
}

El #[panic_handler] y la ausencia de main que viste en bare-metal valen, casi sin cambios, para este módulo: es el mismo no_std bajo otra piel.

💡
no_std primero, std como feature

Si publicas una librería que no necesita intrínsecamente el SO, hazla no_std desde el primer día y ofrece std como feature activada por defecto. Al revés —nacer atada a std y tratar de liberarla después— es un refactor doloroso que suele descubrir dependencias ocultas al montículo por todas partes. La regla práctica: presupón lo mínimo, y deja que quien tenga más lo active.

El hilo común

Mira los cuatro mundos juntos y el patrón salta a la vista. core no presupone nada; cada destino reañade lo que puede. El microcontrolador suma quizá un asignador minúsculo y una capa de abstracción de hardware; el núcleo, su propio asignador y sus primitivas de sincronización; WebAssembly, las funciones que le importa el anfitrión; UEFI, sus servicios de arranque. La forma es siempre la misma: core en el centro, y un anillo de capacidades específicas del entorno a su alrededor.

Merece la pena invertir la mirada habitual. La portabilidad tradicional apunta hacia arriba: se programa contra la API más rica y se confía en que exista en todas partes, y falla en cuanto un destino carece de algo. La de no_std apunta hacia abajo: se programa contra la base más pobre —core— y se añade hacia arriba solo cuando el destino lo permite. Esta segunda estrategia no puede fallar por ausencia, porque nunca supuso nada que pudiera faltar. Ese es el sentido profundo de “un mismo core, muchos destinos”.

flowchart TD
CORE((core portable)) --> EMB[embebido microcontroladores]
CORE --> KER[nucleo Rust for Linux]
CORE --> WASM[WebAssembly modulos]
CORE --> FW[firmware y UEFI]
style CORE fill:#a6e3a1,color:#11111b
style EMB fill:#89b4fa,color:#11111b
style KER fill:#cba6f7,color:#11111b
style WASM fill:#f9e2af,color:#11111b
style FW fill:#f38ba8,color:#11111b
Portabilidad por sustracción, no por adición

La receta habitual para la portabilidad es añadir una capa de abstracción gorda que emula un mismo entorno sobre todos los demás: una máquina virtual, un runtime de mínimo común denominador que cada programa arrastra a todas partes para fingir que el chip y el servidor son la misma cosa. Rust invierte la receta por completo. En vez de añadir una capa que pretende que todos los destinos son iguales, sustrae toda suposición hasta llegar a core, que no presupone absolutamente nada, y entonces deja que cada destino reañada con precisión las capacidades que de verdad tiene. El resultado es que el mismo Iterator, el mismo Result, la misma disciplina del borrow checker corren sin una sola modificación desde un servidor en la nube hasta un chip más pequeño que un grano de arroz —no porque un runtime haya tapado las diferencias, sino porque core nunca las supuso en primer lugar—. Por eso “un mismo core, muchos destinos” no es un eslogan, sino una arquitectura: portabilidad lograda quitando, no poniendo. Y es exactamente la puerta a todo lo que hay por debajo de la capa de aplicación —el núcleo, el firmware, el mundo embebido del nivel 42—, esos territorios donde durante cuarenta años solo reinó C, y a los que Rust llega ahora con sus garantías intactas: sin null, sin use-after-free, sin data races, en el metal desnudo. El viaje que empezó con un #![no_std] que apagaba suposiciones termina abriendo, entero, el reino de la programación de sistemas.

📝
Lo esencial de los mundos

no_std es una familia de destinos, no un lugar: embebido y bare-metal (nivel 42), el núcleo (Rust for Linux, desde Linux 6.1), WebAssembly y firmware o UEFI comparten el mismo core sin dependencias. Las librerías se escriben no_std con alloc y std como features para correr en todos ellos y también en programas std corrientes; el patrón es #![cfg_attr(not(feature = "std"), no_std)], gateado hasta el nivel de cada elemento. La idea unificadora es portabilidad por sustracción: core no presupone nada, y cada destino reañade lo que puede ofrecer.

⚔️ Reconoce el mismo corazón en cada mundo
  1. Elige dos de los cuatro mundos y enumera qué reañade cada uno sobre core: asignador, primitivas, entrada, servicios.
  2. Configura las features std/alloc/no_std de una librería tuya y compílala en las tres variantes; comprueba que el código fuente no cambia.
  3. Investiga la crate kernel de Rust for Linux y localiza una abstracción segura que envuelva una API en C del núcleo.
  4. Compila un “hola mundo” mínimo a wasm32-unknown-unknown en no_std y compara su tamaño con la versión std.
  5. Argumenta por qué una librería no_std bien diseñada es estrictamente más reutilizable que una equivalente atada a std, y qué prepara todo esto para el embebido del nivel 42.