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

no_std: enlazar contra core en vez de std

El atributo `#![no_std]` corta la librería estándar y su prelude, y enlaza en su lugar contra `core`: el subconjunto de Rust que no presupone sistema operativo ni asignador. No es Rust mutilado, sino Rust despojado de sus suposiciones sobre el entorno. Qué apaga, qué enciende, y por qué `std` es en realidad una fachada sobre `core`.

⏱ 18 min

Cada vez que escribes use std::collections::HashMap o llamas a println!, te apoyas en una suposición gigantesca y casi siempre invisible: que debajo hay un sistema operativo. Un núcleo que te presta un asignador de memoria, un planificador de hilos, descriptores de fichero, una pila de red. La librería estándar de Rust —std— es, en buena parte, un envoltorio educado sobre esas capacidades del sistema. Pero hay mundos donde ese sistema no existe: un microcontrolador recién arrancado, un núcleo que es él mismo quien provee esos servicios, un gestor de arranque que corre antes que cualquier SO. Para esos mundos, Rust ofrece un solo atributo que lo cambia todo: #![no_std]. No apaga el lenguaje; apaga sus suposiciones.

🎯 Al terminar esta lección sabrás
  • Entender qué hace exactamente el atributo #![no_std]: cortar std y su prelude, y enlazar contra core.
  • Situar core como el subconjunto de Rust que no presupone ni sistema operativo ni asignador.
  • Comprender que std no es rival de core, sino una fachada construida sobre él y sobre alloc.
  • Reconocer para qué destinos se renuncia a std y por qué a veces es la única opción.

Qué apaga y qué enciende el atributo

#![no_std] es un atributo de crate: se escribe con #! en la primera línea de la raíz del crate y afecta a toda la unidad de compilación. Hace dos cosas precisas. Primero, suprime el extern crate std implícito que Rust inserta por ti: el binario ya no enlazará contra la librería estándar. Segundo, reemplaza el prelude de std por el de core. El prelude es ese puñado de nombres disponibles sin use; al cambiarlo desaparecen de tu alcance Vec, String, Box, println! y HashMap, y permanecen Option, Result, Iterator, Copy, Clone y Ord, porque esos viven en core.

A partir de ahí, donde antes escribías std:: para lo que era puro cálculo, ahora escribes core:::

#![no_std]

use core::cmp::max;              // antes: std::cmp::max
use core::fmt::{self, Write};    // la maquinaria de formato vive en core

fn mayor(a: i32, b: i32) -> i32 {
    max(a, b)                    // calculo puro: core basta y sobra
}

El detalle revelador es que gran parte de std es, literalmente, una reexportación de core. std::mem::swap es core::mem::swap; std::cmp::Ordering es core::cmp::Ordering. No hay dos implementaciones: hay una, en core, que std vuelve a exponer bajo su propio nombre para tu comodidad.

Ese atributo, además, se propaga hacia arriba por el grafo de dependencias. Un crate no_std solo puede depender de crates que a su vez sean no_std, o que ofrezcan una feature para apagar su std. Basta una sola dependencia que enlace std para arrastrar std al binario entero; por eso no_std no es una decisión local de un módulo, sino una propiedad de toda la cadena de crates que desemboca en tu binario. Comprobarlo es sencillo: si tu crate compila con #![no_std] pero falla al añadir una dependencia, casi siempre es porque esa dependencia no ofrece una variante sin std.

core: el corazón portable

core es la fundación del lenguaje. No tiene dependencias, no presupone plataforma alguna y está siempre disponible —incluso std se construye encima de él—. Contiene todo aquello que es cálculo puro y que no necesita ni un asignador de memoria ni una llamada al sistema:

🧱

Tipos y primitivos

Todos los escalares (i32, f64, bool, char) con sus métodos, más Option, Result, tuplas, arrays y referencias.

🔁

Iteradores y traits

El trait Iterator y sus adaptadores perezosos, y los traits fundamentales: Clone, Copy, PartialEq, Ord, la familia Fn.

🔩

mem, ptr y atomics

core::mem (size_of, swap, replace), core::ptr, MaybeUninit, NonNull y core::sync::atomic.

✂️

Slices y str

Los métodos de &[T] y &str —buscar, cortar, iterar— que no asignan: operan sobre memoria que ya posees.

Lo que core no trae es igual de definitorio: nada de montículo (no hay Box, Vec ni String, porque asignar en el heap exige un asignador) y nada que mire al sistema operativo (ni ficheros, ni hilos, ni red, ni reloj de pared, ni println!). La frontera es nítida: si algo se puede calcular sin pedirle nada al mundo, está en core; si necesita memoria dinámica o un syscall, no.

Hay una consecuencia práctica enorme en que core no dependa de nada: está disponible para todo destino que tenga un backend de compilación, exista o no un SO para él. Esa es la razón de fondo por la que Rust cruza sin fricción de un servidor a un microcontrolador a un módulo WebAssembly: no porque std sea muy portable, sino porque debajo de std hay un core que no presupone plataforma alguna y compila igual para todos. La portabilidad de Rust empieza en la ausencia de suposiciones de core, no en la abundancia de std.

ℹ️
No hay println!, pero sí write!

Chocarás pronto con la ausencia de println!: imprimir requiere una salida estándar, y eso es un servicio del SO. Pero la maquinaria de formato vive en core: puedes implementar core::fmt::Write sobre tu propio destino —un buffer, un puerto serie— y usar write! contra él con total normalidad. Lo que falta no es formatear, sino el sumidero; ese lo pones tú.

Ese es un patrón central de no_std, así que conviene verlo hecho. Implementar core::fmt::Write sobre un buffer en la pila da toda la potencia de write! sin asignar un solo byte en el montículo:

use core::fmt::{self, Write};

/// Un escritor sin heap: formatea dentro de un buffer fijo.
struct BufferFijo<'a> {
    datos: &'a mut [u8],
    usado: usize,
}

impl Write for BufferFijo<'_> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        let libre = &mut self.datos[self.usado..];
        let bytes = s.as_bytes();
        if bytes.len() > libre.len() {
            return Err(fmt::Error);          // no cabe: fallo sin asignar
        }
        libre[..bytes.len()].copy_from_slice(bytes);
        self.usado += bytes.len();
        Ok(())
    }
}

Con solo write_str implementado, write!(buffer, "valor: {n}") funciona: core deriva de ahí todo el formateo de enteros, flotantes y estructuras. La lógica de formato era de core desde el principio; lo único que tú aportas es a dónde van los bytes.

Por qué y para qué renunciar a std

No se elige no_std por minimalismo estético, sino porque en el destino no hay un SO que respalde a std. Un microcontrolador no tiene sistema de ficheros ni planificador; un núcleo de sistema operativo es justamente quien provee esos servicios, así que no puede depender de ellos; un gestor de arranque corre antes de que exista SO alguno. En esos entornos, std ni siquiera enlazaría: sus lang items y sus llamadas al sistema no tendrían nada a lo que atarse. no_std es la única vía para ejecutar Rust sobre el metal desnudo, dentro de un núcleo o por debajo de un sistema.

no_std, no_main y abort: tres decisiones ortogonales

Conviene separar de entrada tres elecciones que suelen aparecer juntas y por eso se confunden, pero que son independientes. #![no_std] elige la librería: core en lugar de std. #![no_main] elige el punto de entrada: renuncia al envoltorio que prepara el terreno y llama a tu main. Y panic = "abort" elige la estrategia de pánico: abortar en vez de desenrollar la pila.

#![no_std]     // 1) libreria: enlaza core en vez de std
#![no_main]    // 2) entrada: sin el envoltorio que llamaria a main
// 3) estrategia de panico, aparte, en Cargo.toml: panic = "abort"

Un binario no_std hospedado —con un SO debajo y la libc enlazada— puede conservar un main convencional; y un programa con std puede fijar panic = "abort" sin renunciar a nada más. La confusión nace de que el caso más común, el bare-metal, activa los tres ejes a la vez. Pero son ortogonales, y esta lección trata solo del primero: sustituir std por core. El punto de entrada y el manejador de pánico —los otros dos ejes— son el asunto de la lección cuatro.

En la práctica, el destino elige por ti. Los target triples sin sistema operativo llevan la marca -none, y para ellos std sencillamente no existe como artefacto precompilado: la única opción es no_std.

# un destino sin SO: el triple termina en -none, y std no esta disponible
cargo build --target thumbv7em-none-eabihf

Cuando compilas contra uno de esos triples, olvidar #![no_std] produce un error inmediato: no hay std que enlazar. El atributo no es tanto una elección estilística como el reconocimiento de una realidad del hardware.

flowchart TD
STD[std la libreria estandar] --> CORE[core sin SO ni heap]
STD --> ALLOC[alloc heap con un asignador]
STD --> OSCAPA[capa de sistema ficheros hilos red reloj]
NOSTD[crate con no_std] --> CORE
style CORE fill:#a6e3a1,color:#11111b
style STD fill:#89b4fa,color:#11111b
style NOSTD fill:#cba6f7,color:#11111b
style OSCAPA fill:#f38ba8,color:#11111b
core es el lenguaje; std es una de sus encarnaciones

La intuición que casi todo el mundo trae de otros lenguajes es que “la librería estándar” y “el lenguaje” son lo mismo, o casi. Rust rompe esa identidad de raíz, y #![no_std] es la grieta por donde se ve. El lenguaje de verdad —el que el compilador conoce, el que define qué es un Iterator, qué hace ?, cómo se compara un Ord— vive en core, un crate sin una sola dependencia que no presupone absolutamente nada sobre dónde correrá tu código. std no es el lenguaje: es una encarnación de él, la que asume que debajo hay un sistema operativo dispuesto a prestar memoria, hilos y ficheros. Cuando escribes #![no_std] no usas “un Rust reducido” ni renuncias a la seguridad, al borrow checker o a los traits; todo eso está en core y sigue intacto. Renuncias a las suposiciones sobre el entorno, no a las garantías del lenguaje. Por eso la misma disciplina de ownership que te protege en un servidor te protege idéntica en un chip de dos kilobytes de RAM. Comprender que core es el corazón y std una fachada opcional sobre él es el giro mental que abre entera la programación de sistemas: a partir de aquí, Rust deja de ser “un lenguaje con librería estándar” y pasa a ser “un lenguaje que se adapta, capa a capa, a exactamente lo que su destino puede ofrecer”.

📝
Lo esencial de no_std

#![no_std] es un atributo de crate que suprime el extern crate std implícito y cambia el prelude de std por el de core. core es la fundación del lenguaje sin dependencias: tipos, Option/Result, iteradores, traits, slices y mem/ptr/atomic —todo lo que es cálculo puro—. std es una fachada sobre core y alloc que añade lo que exige un sistema operativo. Renunciar a std no toca ninguna garantía del lenguaje —ownership, borrow checker y traits siguen intactos—: solo apaga las suposiciones sobre el entorno. Se hace cuando no hay SO debajo, y es independiente de #![no_main] y de panic = abort.

⚔️ Cruza la frontera de std a core
  1. Crea un crate de librería, añade #![no_std] en la raíz y compila; localiza qué símbolos (Vec, println!) dejan de estar en alcance.
  2. Sustituye cada std:: que puedas por su equivalente core:: y comprueba cuáles compilan sin cambio: son los puramente de cálculo.
  3. Investiga tres funciones concretas de std que sean solo pub use de core (pista: mira std::mem y std::cmp).
  4. Implementa core::fmt::Write sobre un buffer fijo de bytes y escribe en él con write!, sin String ni println!.
  5. Argumenta por qué un núcleo de sistema operativo no puede depender de std aunque el hardware sea potente.