wandres.dev
PUNTEROS CRUDOS · los cinco superpoderes

Los peligros: UB, y por qué Vec se construye sobre punteros crudos

El catálogo del comportamiento indefinido de los punteros crudos: colgantes, aliasing indebido, desalineación, valores inválidos. Por qué UB no significa un crash sino que el compilador optimiza asumiendo que no ocurre. Miri como red de seguridad, y la lección final: Vec, listas y toda estructura de bajo nivel son un núcleo unsafe con una fachada segura.

⏱ 19 min

Llegamos al filo. Todo lo anterior —crear, dereferenciar, la aritmética, NonNull— entrega poder; esta lección cobra la factura. Los punteros crudos son la vía por la que el comportamiento indefinido, el temido UB, entra en un programa Rust, y hay que entenderlo con exactitud porque casi todo el mundo lo malinterpreta. UB no es “un fallo que crashea”: es una promesa que rompes y sobre la que el compilador ya había construido. Cuando dereferencias un puntero colgante, cuando dejas que dos &mut se solapen, cuando lees un bool que no es ni 0 ni 1, no estás provocando un error definido: estás invalidando una suposición que el optimizador dio por cierta, y a partir de ahí el programa puede hacer literalmente cualquier cosa. Y sin embargo —esta es la paradoja que corona el nivel— es precisamente sobre estos punteros peligrosos, encapsulados con disciplina, sobre lo que Vec, las listas, Rc y toda estructura de datos seria de Rust están construidas.

🎯 Al terminar esta lección sabrás
  • Enumerar las formas de UB con punteros crudos: colgantes, aliasing indebido, desalineación, valores inválidos.
  • Entender por qué UB no es un crash, sino licencia del compilador para asumir que no ocurre.
  • Usar Miri y las comprobaciones de depuración como red para cazar UB que no se manifiesta.
  • Explicar el patrón fundacional: un núcleo unsafe con una fachada pública segura y sound.

El catálogo del comportamiento indefinido

El UB con punteros no es un monstruo único sino una familia. Conviene conocer sus caras porque cada estructura de datos que escribas tendrá que esquivarlas todas a la vez.

⚰️

Colgante y use-after-free

El puntero apunta a memoria ya liberada o a una variable que salió de ámbito. Leerla o escribirla accede a bytes que ya no te pertenecen.

👯

Aliasing indebido

Dos &mut al mismo sitio, o una &mut que se solapa con una &. Es UB aunque nunca las uses mal, porque el compilador asume que una &mut es única.

📐

Desalineación

Dereferenciar donde la dirección no respeta la alineación de T. Usa read_unaligned/write_unaligned cuando no puedas garantizarla.

👻

Valor inválido

Leer un bool que no es 0 ni 1, un char fuera de rango, un enum con discriminante imposible, o memoria sin inicializar. El patrón de bits debe ser legítimo para el tipo.

fn colgante() -> *const i32 {
    let local = 7;
    &raw const local     // apunta a la pila que muere al retornar
}                         // dereferenciar el resultado luego = use-after-free

let mut n = 1_i32;
let p = &raw mut n;
unsafe {
    let a = &mut *p;
    let b = &mut *p;     // dos referencias mutables al mismo sitio: UB aqui mismo
    *a += 1;
    *b += 1;             // el compilador asume que a y b no se solapan
}

Ninguno de estos ejemplos tiene por qué crashear al ejecutarlo. Ese es exactamente el problema.

UB no es un crash: es una licencia

La idea que hay que interiorizar, y que separa a quien escribe unsafe con criterio de quien juega a la ruleta rusa, es esta: el comportamiento indefinido no está definido que falle. El compilador de Rust, vía LLVM, optimiza asumiendo que el UB nunca ocurre. Cuando declaras una &mut, LLVM la marca noalias y reordena, cachea y elide accesos confiando en que nadie más toca esa memoria. Si tú rompes esa promesa con un puntero crudo que aliasea, no obtienes un error ordenado: obtienes un programa cuyo comportamiento el compilador dedujo bajo una premisa falsa. Puede funcionar hoy, fallar mañana al cambiar de versión, o —lo peor— producir resultados sutilmente equivocados sin síntoma alguno.

🛑
El UB que funciona es el más peligroso

Que un programa con UB dé el resultado esperado no significa que sea correcto: significa que el optimizador aún no ha tenido motivo para explotar la premisa que rompiste. Un cambio de compilador, una bandera de optimización distinta, una función que se inlinea, y el comportamiento cambia sin que tu código lo haga. El UB no es una bomba que estalla al pisarla; es una mina que decide cuándo estallar.

Por eso cada dereferencia debe respetar, sin excepción, el contrato completo: no nulo, alineado, dentro de una asignación viva, apuntando a un valor inicializado y válido del tipo, y con el aliasing en regla. La red de seguridad se llama Miri: un intérprete del Rust intermedio que ejecuta tu código vigilando el modelo de memoria —Stacked Borrows o Tree Borrows, alineación, inicialización, límites— y aborta con un diagnóstico preciso en cuanto detecta UB, incluso el que no se manifiesta en hardware real. Ejecutar cargo +nightly miri test sobre el código unsafe es una disciplina, no un lujo. Las debug_assertions y los sanitizers complementan la vigilancia en tiempo de ejecución.

ℹ️
La costumbre del comentario SAFETY

Cada bloque unsafe que sobreviva a una revisión seria lleva encima un comentario // SAFETY: que enuncia por qué las invariantes se cumplen justo ahí. No es burocracia: es la traducción a prosa de la prueba que el compilador ya no hace por ti, y lo que permite que otra persona —o tú dentro de seis meses— audite la frontera sin reconstruir el razonamiento desde cero.

La superestructura insegura con fachada segura

Y ahora la vuelta de tuerca que da sentido a todo el nivel. Si los punteros crudos son tan peligrosos, ¿por qué el corazón de la biblioteca estándar está hecho de ellos? Porque la seguridad en Rust no es una propiedad de cada instrucción, sino de la frontera de un módulo. Un Vec es, por dentro, un puntero crudo NonNull<T>, una longitud y una capacidad, más operaciones unsafe de reserva, copia y liberación. Pero ese núcleo peligroso está encapsulado: el módulo mantiene en privado un puñado de invariantes —la capacidad reservada, los primeros len elementos inicializados, el puntero válido— y expone una API pública que ninguna entrada puede usar para provocar UB. Eso es lo que significa que un tipo sea sound: su interfaz segura es imposible de romper desde fuera.

use std::alloc::{alloc, Layout};
use std::ptr::NonNull;

pub struct Pila<T> {
    datos: NonNull<T>,   // nunca nulo; dangling cuando cap == 0
    len: usize,          // invariante: los primeros len elementos estan vivos
    cap: usize,          // invariante: hay sitio reservado para cap elementos
}

impl<T> Pila<T> {
    pub fn nueva() -> Self {
        Pila { datos: NonNull::dangling(), len: 0, cap: 0 }
    }

    pub fn push(&mut self, v: T) {     // firma segura: nadie de fuera ve el unsafe
        if self.len == self.cap {
            self.crecer();             // reserva o duplica capacidad (unsafe dentro)
        }
        unsafe { self.datos.as_ptr().add(self.len).write(v); }
        self.len += 1;                 // se restablece la invariante antes de volver
    }

    // crecer(), pop(), Drop y Deref a &[T] van aqui, cada uno
    // guardando las invariantes tras su propio bloque unsafe.
}

Fíjate en el reparto: push toca punteros crudos y unsafe por dentro, pero su firma es tan segura como cualquier otra, y el usuario del Vec jamás escribe unsafe para llenarlo. El peligro no se elimina —es irreductible en el nivel más bajo—; se concentra en un módulo pequeño, auditable y protegido por invariantes, para que el resto del programa se construya sobre una fachada demostrablemente segura. Toda estructura de datos de Rust —Vec, VecDeque, HashMap, LinkedList, Rc, Box— es una variación de este mismo patrón.

flowchart TD
A[Nucleo unsafe punteros crudos alloc y dealloc] --> B[Modulo que mantiene invariantes en privado]
B --> C[API publica segura que ninguna entrada puede romper]
C --> D[El resto del programa se construye sobre codigo seguro]
M[Miri vigila el nucleo en las pruebas] --> A
style A fill:#f38ba8,color:#11111b
style B fill:#f9e2af,color:#11111b
style C fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b
style M fill:#cba6f7,color:#11111b
La soundness es una propiedad de la frontera, no de la instrucción

El error más profundo al pensar en unsafe es creer que la seguridad se juega instrucción a instrucción, que cada *p es un pecado individual que hay que expiar. No lo es. La seguridad en Rust es una propiedad global de una frontera: la pregunta correcta nunca es “¿es segura esta dereferencia?” en el vacío, sino “¿puede algún uso de la API pública de este módulo, por retorcido que sea, provocar comportamiento indefinido?”. Si la respuesta es no, el módulo es sound, y da igual cuántos punteros crudos hiervan en su interior. Un Vec es la prueba viviente de esta idea: contiene todo lo que este nivel te enseñó a temer —punteros que podrían colgar, aritmética que podría salirse, memoria sin inicializar tras la longitud, escrituras crudas— y sin embargo es una de las abstracciones más seguras que existen, porque su autor demostró, de una vez y para siempre, que las invariantes privadas que mantiene hacen imposible que un usuario externo alcance el UB. Ahí está el contrato social del unsafe: quien escribe el bloque unsafe asume una obligación de prueba que se extiende más allá de esas líneas, hasta abarcar todo lo que la interfaz permite hacer con ellas. Por eso encapsular es la única forma honesta de usar punteros crudos. Esparcirlos por todo el programa multiplica la superficie que hay que verificar hasta lo inauditable; concentrarlos en un módulo con una fachada segura reduce esa superficie a algo que una persona puede leer, razonar y comprobar con Miri en una tarde. La biblioteca estándar no es segura porque evite los punteros crudos: es segura porque los domina, encerrándolos donde no pueden hacer daño y ofreciendo al resto del mundo solo la superficie pulida. Aprender punteros crudos no es, al final, aprender a vivir peligrosamente. Es aprender a construir el suelo firme sobre el que los demás caminan sin mirar abajo.

📝
Lo esencial de los peligros y la encapsulación

El UB de los punteros —colgantes, aliasing indebido, desalineación, valores inválidos— no es un crash sino licencia del compilador para optimizar asumiendo que no ocurre; por eso el UB que “funciona” es el más traicionero. Cada dereferencia debe cumplir el contrato completo, y Miri es la red que caza lo que el hardware no manifiesta. La paradoja final: Vec, las listas y Rc están hechos de estos mismos punteros, encapsulados en un módulo cuyas invariantes privadas vuelven su API pública imposible de romper. La soundness es una propiedad de la frontera, no de la instrucción.

⚔️ Encierra el peligro tras una fachada
  1. Escribe una función que devuelva un puntero a una variable local y razona, sin ejecutarla, por qué dereferenciar el resultado es use-after-free.
  2. Construye con un puntero crudo dos &mut solapadas y explica por qué es UB en el momento de crearlas, no al usarlas; instala Miri y compruébalo con cargo +nightly miri run.
  3. Fuerza una lectura desalineada con un #[repr(packed)] y observa cómo read_unaligned la vuelve legítima; explica qué invariante rescatas.
  4. Amplía la Pila<T> del texto con crecer (usando alloc y Layout), pop y un Deref a &[T]; identifica la invariante que cada método debe restablecer antes de retornar.
  5. Argumenta por qué Pila<T> puede ofrecer una API totalmente segura pese a estar hecha de punteros crudos, apelando a la soundness como propiedad de la frontera.