Los errores clásicos: colgantes, salvajes y dobles liberaciones
Puntero no inicializado, puntero colgante, doble liberación y desreferencia de NULL. Cómo se manifiesta cada uno, por qué el síntoma aparece lejos de la causa y qué disciplina los elimina de raíz.
Cuatro errores concentran una fracción desproporcionada de los fallos y las vulnerabilidades escritas en C durante cincuenta años. Ninguno es sofisticado y todos son evitables con hábitos mecánicos. Lo que los hace peligrosos no es su dificultad conceptual, sino que casi nunca fallan donde los cometes: corrompen silenciosamente y estallan mucho después, en otra función, otro hilo o solo en producción.
- Identificar el puntero no inicializado y la desreferencia de
NULL. - Entender el puntero colgante y el uso tras liberar como el mismo fallo.
- Reconocer la doble liberación y por qué corrompe el asignador.
- Adoptar la disciplina de propiedad e inicialización que previene los cuatro.
Punteros que no apuntan a nada válido
Los dos primeros errores son opuestos y complementarios. Un puntero no inicializado contiene basura: lo que hubiera en esa posición de la pila. Desreferenciarlo es comportamiento indefinido y el resultado es una lotería: puede fallar, puede escribir sobre otra variable tuya o puede parecer que funciona.
int *salvaje; // contiene basura: no apunta a nada conocido
// *salvaje = 42; // UB: escritura en una direccion arbitraria
int *seguro = nullptr; // C23: intencion explicita, sin ambiguedad
La desreferencia de un puntero nulo es el caso opuesto: sabes exactamente que no apunta a nada. En la práctica suele ser el error más benigno, porque casi todas las plataformas dejan la página cero sin mapear y el sistema aborta el proceso de inmediato. Pero benigno no significa definido: el estándar lo declara comportamiento indefinido, y el compilador aprovecha esa licencia para eliminar comprobaciones que considera imposibles.
El origen habitual es no comprobar el retorno de una función que puede fallar:
#include <stdlib.h>
#include <string.h>
char *copia(const char *origen)
{
char *destino = malloc(strlen(origen) + 1);
if (destino == nullptr) // la comprobacion que casi nadie escribe
return nullptr;
strcpy(destino, origen);
return destino;
}
Nótese la diferencia semántica entre ambos fallos, que es la razón de preferir siempre nullptr a dejar un puntero sin inicializar. Un puntero nulo es comprobable: existe un test que distingue el estado válido del inválido. Un puntero salvaje no lo es, porque ninguna comparación puede distinguir basura de una dirección legítima. En C23, nullptr tiene además su propio tipo, nullptr_t, lo que elimina las ambigüedades históricas de la macro NULL en funciones variádicas.
El puntero colgante y el uso tras liberar
Un puntero colgante apuntó a algo válido y ya no lo hace: el objeto murió pero el puntero sigue guardando su dirección. Es el más traicionero de los cuatro porque el puntero conserva un valor numérico plausible.
Hay dos formas de crearlo. La primera es liberar la memoria y seguir usando el puntero:
#include <stdlib.h>
int *p = malloc(sizeof *p);
*p = 42;
free(p); // la memoria vuelve al asignador
// *p = 7; // use-after-free: p sigue apuntando ahi, pero ya no es tuyo
p = nullptr; // el habito que corta el problema de raiz
La segunda es devolver la dirección de una variable local, cuya vida termina al salir de la función:
int *mal(void)
{
int local = 42;
return &local; // el marco de pila muere al volver: puntero colgante
}
Lo que hace tan difícil de depurar el uso tras liberar es que la memoria liberada no se borra. Justo después del free, los bytes suelen conservar el valor antiguo, así que el programa parece correcto en las pruebas. El desastre llega cuando otra parte del código reutiliza ese bloque: entonces dos partes del programa escriben sobre la misma memoria creyendo cada una que es suya. Este patrón es, además, la base de una familia entera de exploits, porque un atacante que controle la reasignación decide qué objeto ocupa el hueco.
La doble liberación
Llamar dos veces a free sobre el mismo bloque corrompe las estructuras internas del asignador. No es un error de tu programa contra sí mismo: es un error contra el gestor de memoria.
int *p = malloc(sizeof *p);
free(p);
// free(p); // doble liberacion: corrompe las listas del asignador
Casi siempre nace de una propiedad ambigua: dos punteros al mismo bloque y dos partes del código que creen ser responsables de liberarlo. Los asignadores modernos suelen detectarlo y abortar con un mensaje, pero esa detección es una cortesía de la implementación, no una garantía del lenguaje.
Hay un detalle que conviene fijar porque resuelve el problema entero: free(nullptr) es una operación definida y segura que no hace nada. Esa garantía del estándar es lo que convierte el hábito de anular el puntero tras liberarlo en una defensa completa, y no en un simple paliativo.
Conviene también recordar qué no es una doble liberación, porque el error simétrico existe y también corrompe: pasar a free un puntero que no salió de malloc, calloc o realloc. Liberar la dirección de una variable local, o un puntero que fue desplazado con aritmética después de la reserva, es tan indefinido como liberar dos veces.
int *p = malloc(4 * sizeof *p);
p++; // el puntero ya no apunta al inicio del bloque
// free(p); // UB: free exige exactamente la direccion devuelta
No inicializado
Contiene basura. No hay comprobación posible que lo distinga de uno válido. Se evita inicializando siempre a nullptr.
Nulo desreferenciado
Suele abortar de inmediato, y aun así es comportamiento indefinido. Se evita comprobando todo retorno que pueda fallar.
Colgante
Apunta a memoria ya muerta con un valor plausible. Se evita anulando tras liberar y no devolviendo direcciones de locales.
Doble liberación
Corrompe el asignador. Se evita con propiedad única: un solo dueño responsable de cada bloque.
flowchart TD A[Puntero declarado] -->|sin inicializar| B[Salvaje: apunta a basura] A -->|inicializado a nullptr| C[Nulo: invalido pero comprobable] C -->|malloc con exito| D[Valido: apunta a memoria propia] D -->|free| E[Colgante si no se anula] D -->|free y luego nullptr| C E --> F[Use after free o doble liberacion] style B fill:#f38ba8,color:#11111b style F fill:#f38ba8,color:#11111b style D fill:#a6e3a1,color:#11111b style C fill:#f9e2af,color:#11111b
La disciplina que los previene
Ninguno de los cuatro errores se evita siendo cuidadoso: se evitan con reglas mecánicas que no dependen de tu atención en un día malo.
- Inicializa siempre. Ningún puntero debe existir sin valor. Si aún no apunta a nada, apunta a
nullptr. - Anula después de liberar.
free(p); p = nullptr;convierte cualquier uso posterior en un fallo inmediato y comprobable en vez de una corrupción silenciosa. - Comprueba antes de desreferenciar todo puntero que provenga de una función que puede fallar.
- Declara el dueño. Cada bloque tiene exactamente un propietario responsable de liberarlo; los demás punteros son prestados y no liberan nunca.
- Documenta la propiedad en la firma. Si una función se queda con el puntero o devuelve uno que hay que liberar, dilo en un comentario junto al prototipo.
La regla de la propiedad única es la más importante porque ataca la causa común de tres de los cuatro fallos. Y por encima de la disciplina está la verificación automática: compila siempre tu código de desarrollo con -fsanitize=address,undefined. El detector de direcciones convierte el uso tras liberar y la doble liberación en abortos inmediatos con traza completa, señalando el punto de asignación, el de liberación y el de uso indebido.
El error mental que subyace a los cuatro bugs de esta lección es creer que el comportamiento indefinido es una forma de fallo con síntoma. Se piensa: desreferencio algo inválido, luego crashea, luego lo veré. Falso, y la distancia entre esa creencia y la realidad explica por qué C tiene la reputación que tiene. El comportamiento indefinido no describe qué ocurre en esa línea; declara que el estándar deja de decir nada sobre la ejecución completa, incluidas las líneas anteriores a la infracción. El compilador está autorizado a razonar hacia atrás: si una ruta contiene comportamiento indefinido, esa ruta no puede ocurrir en un programa correcto, luego cualquier condición que llevara a ella es falsa, luego la comprobación que la vigilaba es código muerto y se elimina. Así es como una desreferencia de NULL colocada después de una comprobación termina borrando esa comprobación —el compilador deduce que el puntero no podía ser nulo, porque si lo fuera el programa no tendría significado—. De ahí la lección que gobierna todo lo que escribas en este lenguaje: la corrección en C no es una propiedad observable, no se establece probando. Que el programa dé el resultado correcto no demuestra nada, porque un programa con comportamiento indefinido puede funcionar durante años y romperse al cambiar de compilador, de nivel de optimización o de disposición de memoria. La corrección solo se establece por construcción: invariantes que puedas enunciar, propiedad que puedas señalar, disciplina que no dependa de tu memoria. Las reglas de esta lección no son consejos de higiene; son la única forma de que tu programa signifique algo.
- Escribe un programa con un puntero no inicializado, compílalo con
-Wall -Wextray comprueba si el compilador te avisa. - Provoca un uso tras liberar deliberado y ejecútalo con
-fsanitize=address; identifica en el informe el punto de asignación, el de liberación y el de uso. - Escribe una función que devuelva la dirección de una variable local, activa los avisos y explica por escrito por qué el puntero es inválido al volver.
- Provoca una doble liberación y observa qué hace tu asignador; después corrígela anulando el puntero y verifica que
free(nullptr)no rompe nada. - Coge una función tuya que reserve memoria y documenta en su prototipo quién es el propietario del bloque devuelto y quién debe liberarlo.