wandres.dev
ALLOCATORS Y MEMORIA · a bajo nivel

no_std y alloc: recuperar Box y Vec aportando tu propio allocator

Box, Vec y String no son parte del lenguaje ni dependen del sistema operativo: viven en el crate `alloc`, que solo pide una cosa para funcionar —un allocator global—. Un binario embebido o un kernel, sin `std` bajo los pies, recuperan todas las colecciones de alto nivel con solo implementar `GlobalAlloc` sobre la memoria que ellos gestionan.

⏱ 20 min

Aquí se cierra el círculo de todo el nivel. En la primera lección viste que Box y Vec sacan su memoria del allocator global; ahora vas a ver qué ocurre cuando no hay sistema operativo debajo que provea ese allocator. Un microcontrolador, un kernel, un cargador de arranque: entornos #![no_std] donde no existe std porque no hay OS que dé hilos, ficheros ni un malloc de sistema. La pregunta es si en ese desierto pierdes Vec y String. La respuesta —y es una de las decisiones de diseño más elegantes de Rust— es que no: esas colecciones no viven en std ni en el lenguaje, sino en un crate intermedio llamado alloc, que funciona en cualquier parte a cambio de una sola cosa: que tú aportes un allocator global. Implementa GlobalAlloc sobre la RAM que tu hardware controla, y todo el ecosistema de colecciones de alto nivel se enciende sobre metal desnudo.

🎯 Al terminar esta lección sabrás
  • Entender la estratificación en tres capas core, alloc y std, y qué capacidad añade cada una.
  • Activar alloc en un binario #![no_std] con extern crate alloc; y recuperar Box, Vec, String.
  • Aportar el allocator con #[global_allocator] sobre una región de memoria propia.
  • Situar el #[alloc_error_handler] y por qué un entorno sin OS debe decidir qué hacer ante falta de memoria.

Tres capas: core, alloc, std

La biblioteca de Rust no es monolítica; está partida en tres crates apilados según lo que exigen del entorno:

  • core es el corazón sin dependencias: tipos primitivos, Option, Result, iteradores, traits. No asigna memoria y no supone nada del entorno; funciona incluso sin heap. Todo programa Rust lo tiene, no_std incluido.
  • alloc añade justo lo que necesita un heap: Box, Vec, String, Rc, Arc, BTreeMap. No supone un sistema operativo, pero sí exige un allocator global que le diga de dónde sacar y a dónde devolver memoria.
  • std es core más alloc más todo lo que necesita un OS: hilos, ficheros, sockets, HashMap, y un allocator por defecto (System) ya montado sobre el malloc de la plataforma.

Un detalle revelador de dónde cae la frontera: BTreeMap vive en alloc, pero HashMap vive en std. La razón no es el heap —ambos asignan— sino que el HashMap estándar siembra su función de hash con aleatoriedad del sistema operativo para resistir ataques de colisiones, y esa entropía es un servicio del OS que alloc no puede asumir. La estratificación no separa por “qué tan avanzado” es el tipo, sino por qué capacidad exacta del entorno exige cada uno.

Un binario normal usa std y no piensa en esto. Un binario #![no_std] renuncia a std —porque no hay OS—, se queda con core gratis, y puede optar a alloc si acepta el trato: aportar el allocator que std le daba hecho.

flowchart TD
C[core sin heap ni OS siempre disponible] --> AL[alloc anade Box Vec String]
AL --> ST[std anade hilos ficheros y allocator System]
AL -.exige.-> G[tu implementacion de GlobalAlloc]
C -.base de.-> NS[binario no_std embebido o kernel]
NS -.puede optar a.-> AL
style C fill:#89b4fa,color:#11111b
style AL fill:#cba6f7,color:#11111b
style G fill:#f38ba8,color:#11111b
style ST fill:#a6e3a1,color:#11111b

Encender alloc en no_std

En un binario #![no_std], alloc no viene por defecto: hay que traerlo con extern crate alloc; y luego importar de él lo que uses. En cuanto haya un #[global_allocator] registrado, Box y Vec funcionan igual que en std:

#![no_std]
#![no_main]

extern crate alloc;                 // trae el crate alloc

use alloc::boxed::Box;
use alloc::vec::Vec;
use alloc::string::String;

fn construir() -> Vec<u32> {         // Vec en metal desnudo
    let mut v = Vec::new();
    v.push(1);
    v.push(2);
    let _b = Box::new(99u32);        // Box sobre tu propio heap
    v
}

Lo notable es que construir es idéntico al que escribirías en std. El crate alloc no sabe —ni le importa— si debajo hay un OS o un kernel que tú escribiste: solo sabe que existe un allocator global al que pedir. Toda la diferencia está en quién provee ese allocator.

Aportar el allocator sobre tu memoria

En un embebido o un kernel, tú posees una región de RAM y decides cómo repartirla. El allocator es la implementación de GlobalAlloc que la gestiona. En la práctica se usa un crate probado en vez de escribir el unsafe a mano; linked_list_allocator es el habitual:

use linked_list_allocator::LockedHeap;

#[global_allocator]
static HEAP: LockedHeap = LockedHeap::empty();

// En el arranque, cuando ya conoces la RAM libre (por el mapa del bootloader
// o el linker script), inicializas el heap con su base y su tamano:
unsafe fn init_heap(base: *mut u8, tam: usize) {
    unsafe { HEAP.lock().init(base, tam); }
}

El allocator arranca vacío y se inicializa cuando el hardware ya te dijo qué memoria hay libre. A partir de ese init, cada Box::new y cada Vec::push del programa tallan trozos de tu región. En un microcontrolador, embedded-alloc hace lo mismo sobre un [u8; N] estático reservado en RAM. La conexión con la lección 2 es directa: el allocator debe ser seguro entre hilos o interrupciones, de ahí el Locked que envuelve el heap.

El allocator entero, a mano

Para desmitificarlo del todo, aquí está un allocator global completo y autónomo: un bump —el de la lección anterior— sobre un array estático, sin dependencias ni OS. Solo asigna; nunca libera por objeto:

use core::alloc::{GlobalAlloc, Layout};
use core::cell::UnsafeCell;
use core::sync::atomic::{AtomicUsize, Ordering};

const TAM: usize = 64 * 1024;              // 64 KiB de heap, en la RAM del binario

struct BumpHeap {
    memoria: UnsafeCell<[u8; TAM]>,
    siguiente: AtomicUsize,               // desplazamiento del proximo hueco libre
}

unsafe impl Sync for BumpHeap {}          // afirmo que el acceso concurrente es seguro

unsafe impl GlobalAlloc for BumpHeap {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let base = self.memoria.get() as usize;
        let mut actual = self.siguiente.load(Ordering::Relaxed);
        loop {
            let inicio = (base + actual + layout.align() - 1) & !(layout.align() - 1);
            let fin = inicio + layout.size();
            if fin > base + TAM {
                return core::ptr::null_mut();          // sin memoria: devuelve nulo
            }
            match self.siguiente.compare_exchange_weak(
                actual, fin - base, Ordering::Relaxed, Ordering::Relaxed,
            ) {
                Ok(_) => return inicio as *mut u8,
                Err(previo) => actual = previo,         // otro hilo gano: reintenta
            }
        }
    }
    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}  // bump puro: no libera
}

#[global_allocator]
static HEAP: BumpHeap = BumpHeap {
    memoria: UnsafeCell::new([0; TAM]),
    siguiente: AtomicUsize::new(0),
};

Esas cuarenta líneas encienden Box, Vec y String en un binario sin sistema operativo. La unsafe impl Sync es la afirmación de la lección 37: prometo que el bucle de compare_exchange_weak hace segura la asignación concurrente. El dealloc vacío revela la naturaleza del bump: memoria que solo crece, adecuada para un firmware que asigna en el arranque y no vuelve a hacerlo. Cambia ese cuerpo por una free list y tendrás un allocator reutilizable; esa es, literalmente, la diferencia entre este juguete y linked_list_allocator.

⚠️
Sin OS, decidir qué pasa al quedarse sin memoria es tuyo

En std, si una asignación falla, el allocator por defecto aborta el proceso con un mensaje. En #![no_std] no hay proceso que abortar ni consola donde imprimir, así que la falta de memoria la gobierna el #[alloc_error_handler]: la función que Rust invoca cuando alloc devuelve nulo. Las versiones recientes traen un comportamiento por defecto que entra en panic, pero en firmware serio querrás el tuyo —encender un LED de fallo, reiniciar el dispositivo, registrar en una zona persistente—. Recuerda además try_reserve de la lección 3: en un sistema sin red de seguridad, manejar el fallo de memoria como un Result en vez de dejar que aborte suele ser la opción responsable.

Box y Vec no son magia del lenguaje: son alloc sobre un unico trait que tu puedes proveer

La revelación de esta lección desmonta una intuición casi universal: que Vec y Box son parte de Rust como lo son if o while, características del lenguaje soldadas al compilador. No lo son. Son tipos de librería, escritos en Rust ordinario dentro del crate alloc, y toda su dependencia con el mundo exterior se reduce a un solo trait: GlobalAlloc. Esa es la palanca. Cuando comprendes que la única cosa que separa “tengo Vec y Box” de “no los tengo” es la presencia de una implementación de ese trait, entiendes por qué un kernel escrito en Rust puede usar colecciones de alto nivel que en C exigirían enlazar una libc entera o reescribirlas a mano. El kernel no importa un runtime ni un recolector: implementa alloc y dealloc sobre la memoria física que descubrió en el arranque, la registra con #[global_allocator], y a partir de esa línea Box::new y Vec::push funcionan sobre metal desnudo exactamente igual que en una aplicación de escritorio. La estratificación en tres capas —core, alloc y std— es la que hace esto posible, y es una obra maestra de diseño de dependencias: cada capa nombra con precisión qué exige del entorno —core nada, alloc un allocator, std un OS— de modo que puedes bajarte en la capa exacta que tu hardware soporta y llevarte todo lo que esa capa permite, ni un ápice menos. La lección de sistemas es profunda y general: las abstracciones de alto nivel no tienen por qué costar un entorno pesado si están construidas contra la capacidad mínima que de verdad necesitan, expresada como un trait. Vec necesita saber pedir y devolver memoria, nada más; así que Vec funciona en cualquier lugar donde puedas proveer esas dos operaciones. Aportar tu propio allocator no es un truco de nicho para embebidos: es ejercer, en el punto más bajo del sistema, la misma costura que la lección uno te mostró en el más alto.

📝
Lo esencial de no_std con alloc

La biblioteca se estratifica en core (sin heap ni OS), alloc (Box, Vec, String, Rc, BTreeMap; exige un allocator global) y std (añade OS y el allocator System). En #![no_std] te quedas con core gratis y puedes recuperar alloc con extern crate alloc; a cambio de aportar tú el allocator: un #[global_allocator] que implemente GlobalAlloc sobre la memoria que gestionas (con crates como linked_list_allocator o embedded-alloc). Desde ese registro, Box y Vec funcionan idénticos a como lo hacen en std. Sin OS, la falta de memoria la decide el #[alloc_error_handler]. Box y Vec no son lenguaje: son alloc sobre un único trait que tú puedes proveer.

⚔️ Enciende las colecciones sobre metal desnudo
  1. Dibuja la pila core menos alloc menos std y anota, para cada capa, qué exige del entorno y qué tipos añade.
  2. Explica por qué un binario #![no_std] tiene core gratis pero debe pedir alloc explícitamente con extern crate alloc;.
  3. Escribe la firma de una función #![no_std] que devuelva un alloc::vec::Vec<u32> y razona qué única pieza del programa hace que compile y funcione.
  4. Investiga linked_list_allocator o embedded-alloc y describe cómo se inicializa el heap con la base y el tamaño de una región de RAM en el arranque.
  5. Argumenta, conectando con la lección uno, por qué “aportar tu propio allocator” en un kernel es la misma costura GlobalAlloc que cambiar el allocator de una aplicación, ejercida en el nivel más bajo.