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

no_std: qué pierdes y qué conservas

Cruzar a `no_std` es dibujar un mapa: desaparece todo lo que presupone un sistema operativo —heap, `Vec`/`Box`/`String`, hilos, ficheros, red, reloj, `println!`— y permanece todo lo que es cálculo puro —tipos, `Option`/`Result`, iteradores, traits, slices, atómicos—. Un único criterio separa ambos conjuntos, y con él puedes predecir de memoria en qué lado cae cualquier elemento.

⏱ 17 min

La primera vez que compilas con #![no_std], el compilador te recibe con una avalancha de errores: Vec no existe, println! no existe, Box no existe. Es tentador leerlo como una mutilación, como si Rust se hubiera quedado a medias. No lo es. Lo que estás viendo es una frontera trazada con precisión quirúrgica: de un lado, todo lo que da por hecho un sistema operativo detrás; del otro, todo lo que Rust puede calcular sin pedirle nada al mundo. Aprender ese mapa no es memorizar dos listas, sino interiorizar el único principio que las genera. Cuando lo tengas, sabrás sin abrir la documentación si cualquier tipo o función sobrevive a no_std o no.

🎯 Al terminar esta lección sabrás
  • Enumerar lo que se pierde en no_std: heap, Vec/Box/String, hilos, ficheros, red, tiempo, println!.
  • Enumerar lo que se conserva: tipos, Option/Result, iteradores, traits, slices, atómicos.
  • Interiorizar el criterio único que separa ambos conjuntos: ¿necesita un asignador o un syscall?
  • Reconocer los casos límite, como HashMap (fuera) frente a la maquinaria de fmt (dentro).

Lo que pierdes: todo lo que presupone un SO

Desaparece, en bloque, cualquier cosa que necesite que exista un sistema operativo debajo. No porque Rust lo prohíba, sino porque vive en std y std ya no está enlazado:

📦

El montículo

Box, Vec, String, Rc, Arc, VecDeque, BTreeMap, HashMap: todo lo que asigna dinámicamente necesita un asignador.

🧵

Concurrencia del SO

std::thread, el Mutex del sistema, los canales: dependen del planificador y de las primitivas de sincronización del núcleo.

💾

El mundo exterior

std::fs, std::net, std::io, std::process, std::env: ficheros, red, procesos y entorno son puros syscalls.

⏱️

Tiempo e impresión

SystemTime, Instant, y las macros println!, eprintln!, dbg!: el reloj de pared y la salida estándar son del SO.

También desaparece el andamiaje que el runtime de std montaba en silencio: el manejador de pánico por defecto, el desenrollado de la pila y el main que se ejecutaba solo. Eso lo reconstruirás tú a mano en la lección cuatro.

Lo que conservas: todo lo que es cálculo puro

Y sin embargo, el corazón del lenguaje queda intacto. Nada de lo que define a Rust como Rust depende de std:

#![no_std]

/// Media entera de un slice, sin asignar nada: puro core.
fn media(datos: &[i32]) -> Option<i32> {
    if datos.is_empty() {
        return None;                       // Option: vive en core
    }
    let suma: i32 = datos.iter().sum();    // el iterador no asigna
    Some(suma / datos.len() as i32)
}

Ese fragmento usa Option, un slice, el trait Iterator con su adaptador sum, aritmética y un if, y compila sin std porque todo ello es core. Conservas, íntegros: todos los tipos primitivos y sus métodos; Option, Result y sus combinadores, con el operador ?; el trait Iterator y todos sus adaptadores (map, filter, fold, zip), que son perezosos y no asignan; los traits fundamentales (Copy, Clone, PartialEq, Eq, Ord, Hash el trait, la familia Fn); los slices &[T] y &str con sus métodos que no asignan; genéricos, closures, pattern matching, core::mem, core::ptr y los atómicos de core::sync::atomic. El borrow checker, por descontado, sigue vigilando cada línea.

El mismo principio escala a estructuras de datos completas. Con genéricos, arrays y const generics —todo de core— se construyen colecciones de capacidad fija que jamás tocan el montículo, la base sobre la que se levantan crates como heapless:

/// Una pila de capacidad fija: puro core, sin asignar en el heap.
struct PilaFija<T, const N: usize> {
    datos: [Option<T>; N],
    cima: usize,
}

impl<T: Copy, const N: usize> PilaFija<T, N> {
    fn nueva() -> Self {
        Self { datos: [None; N], cima: 0 }
    }

    fn push(&mut self, x: T) -> Result<(), T> {
        if self.cima == N {
            return Err(x);          // llena: devolvemos el valor sin perderlo
        }
        self.datos[self.cima] = Some(x);
        self.cima += 1;
        Ok(())
    }
}

Option, Result, genéricos, un array de tamaño constante y aritmética: nada de esto abandona core. Tienes estructuras reales, con una API de push que falla cuando se llena en vez de asignar más memoria, y sin un montículo a la vista.

El criterio: asignador o syscall

Las dos listas anteriores no son arbitrarias ni hay que memorizarlas: son la proyección de una sola pregunta. Ante cualquier elemento de la librería, pregúntate si para funcionar necesita asignar en el montículo o hacer una llamada al sistema. Si la respuesta es no, está en core y sobrevive. Si es sí, está fuera.

flowchart TD
Q[necesita heap o un syscall] -->|si lo necesita| FUERA[fuera de core Vec Box hilos ficheros red reloj]
Q -->|no lo necesita| DENTRO[dentro de core Option Result iteradores slices traits atomicos]
style DENTRO fill:#a6e3a1,color:#11111b
style FUERA fill:#f38ba8,color:#11111b
style Q fill:#89b4fa,color:#11111b

El criterio predice incluso los casos que parecen anómalos. Iterator::map no asigna ni llama al sistema: se queda. std::thread::spawn necesita el planificador: se va. Puedes ejercitarlo como un juego, decidiendo el piso de cada elemento sin tocar el compilador:

// slice::windows     -> core   (solo recorre memoria que ya posees)
// str::trim          -> core   (no asigna: devuelve un sub-slice)
// Instant::now       -> std    (le pregunta la hora al sistema)
// Vec::with_capacity -> alloc  (reserva en el monticulo)
// TcpStream::connect -> std    (abre un socket: puro syscall)

Y hay un caso límite famoso que conviene tener presente antes de la próxima lección.

⚠️
HashMap no vuelve ni con montículo

Podrías pensar que, en cuanto recuperes el heap (lección siguiente), tendrás HashMap de nuevo. No es así: HashMap no vive en alloc, sino en std, porque su hasher por defecto extrae una semilla aleatoria del sistema operativo para resistir ataques de colisión (HashDoS). Sin SO, no hay semilla. En no_std con montículo se usa BTreeMap —que sí está en alloc— o la crate hashbrown (la misma implementación que std usa por dentro) con un hasher fijo. Que algo necesite el heap no basta: si además toca el SO, se queda fuera.

📝
Los atómicos existen, pero dependen del hardware

core::sync::atomic está en core, no en std: los atómicos son primitivas del procesador, no del sistema operativo. Pero su disponibilidad la dicta el hardware del destino. Un microcontrolador sin instrucciones atómicas nativas puede carecer de AtomicU64, o incluso de compare-and-swap. Cuando necesites atómicos portables entre destinos dispares, la crate portable-atomic los emula donde el silicio no los ofrece. Es el recordatorio de que la frontera de no_std no es solo “con o sin SO”, sino también “qué sabe hacer este hardware”.

Dos clases de pérdida, dos destinos

No todo lo que perdiste se pierde igual, y la distinción es el mapa de ruta del nivel entero. Las pérdidas de montículoVec, Box, String, Rc, las colecciones— se fueron solo porque necesitan asignar, y son recuperables: basta un asignador, y la próxima lección te las devuelve con el crate alloc, sin traer ningún SO. Las pérdidas de sistema —hilos, ficheros, red, reloj, salida estándar— se fueron porque necesitan un sistema operativo, y ningún asignador las restituye: solo un SO real lo hace.

Por eso la lección tres recupera la primera clase añadiendo alloc, mientras que las lecciones cuatro y cinco enseñan a vivir de forma permanente sin la segunda. Y en tu propio código, saber en qué cubo cae cada dependencia te dice con exactitud hasta qué piso —core, core más alloc, o std— puedes subir en un destino dado.

El mapa no se memoriza: se deduce de un solo principio

La tentación del principiante es tratar no_std como una lista de excepciones que hay que aprender de memoria: “recuerda que Vec no está, que println! no está, que los hilos no están”. Quien lo vive así lucha para siempre, consultando la documentación en cada duda. El experto ha hecho el movimiento contrario: ha comprendido que las dos listas —lo que cae y lo que sobrevive— no son datos, sino consecuencias. Hay un único eje de partición, y es la pregunta “¿esto toca el mundo?”. La librería estándar de Rust está factorizada exactamente sobre esa pregunta: core es, por construcción, el conjunto cerrado de todo lo que es función pura del estado del programa —sin memoria dinámica, sin entrada, sin salida—, y std es ese conjunto más todo lo que exige un entorno que lo respalde. Por eso puedes predecir, sin abrir un solo documento, en qué lado cae cualquier elemento que te encuentres: no porque lo hayas memorizado, sino porque conoces el criterio que lo genera. Esa capacidad predictiva —mirar SystemTime y saber al instante que se va, mirar slice::windows y saber que se queda— es la señal inequívoca de que has entendido la arquitectura en capas, y no solo su superficie. La partición de std no es una lista de la compra: es un teorema con una sola hipótesis.

📝
Lo esencial de la frontera

En no_std se pierde todo lo que presupone un SO —Vec y el resto del montículo, hilos, ficheros, red, reloj, println!— y se conserva todo lo que es cálculo puro —tipos, Option/Result, iteradores, traits, slices, genéricos, const generics, atómicos—. El criterio único: ¿necesita asignar en el heap o hacer un syscall? Con él predices en qué lado cae cualquier elemento sin abrir la documentación. Y las pérdidas son de dos clases: las de montículo vuelven con alloc; las de sistema, solo con un SO. HashMap es el caso límite: ni con heap regresa, porque su hasher pide entropía al sistema.

⚔️ Predice, luego verifica
  1. Para cada uno, di si sobrevive a no_std antes de compilar: Vec::push, slice::iter, String::from, i32::pow, std::fs::read, Option::map. Justifica con el criterio.
  2. Reescribe una función que use un Vec intermedio para que trabaje solo con slices e iteradores, sin asignar. ¿Compila ya en core?
  3. Busca en la documentación dónde vive HashMap y dónde vive BTreeMap, y explica la asimetría con lo visto sobre el hasher.
  4. Toma un adaptador de iterador que creías de std y confirma que en realidad está en core; explica por qué era predecible.
  5. Da un ejemplo de función de tu propio código que sea “pura de core” y otro que necesite el SO, y marca la frontera exacta entre ambas.