wandres.dev
INGENIERO · Memoria dinámica

Fugas y su detección: Valgrind y el sanitizer de direcciones

Qué es exactamente una fuga de memoria y en qué se distingue de la memoria simplemente retenida, por qué el sistema de tipos de C es incapaz de impedirlas y por qué el problema es indecidible en general, y cómo cazarlas con dos tecnologías opuestas: la instrumentación binaria dinámica de Valgrind y la instrumentación en tiempo de compilación de AddressSanitizer y LeakSanitizer.

⏱ 19 min

Una fuga de memoria no rompe nada de forma inmediata. El programa sigue produciendo resultados correctos, las pruebas siguen pasando y el error solo se manifiesta como una degradación lenta que termina, semanas después, en un proceso muerto por falta de memoria en el peor momento posible. Esa ausencia de síntoma inmediato es lo que la convierte en un defecto de una categoría distinta a los demás: no se detecta razonando sobre una ejecución, se detecta contabilizando todas. Esta lección define la fuga con precisión, argumenta por qué el lenguaje no puede evitarla por construcción y presenta las dos herramientas que la hacen visible, comparando lo que cada una ve y lo que cada una cuesta.

🎯 Al terminar esta lección sabrás
  • Definir la fuga en términos de alcanzabilidad y distinguirla de la retención deliberada.
  • Justificar por qué el sistema de tipos de C no puede expresar la propiedad de un bloque.
  • Interpretar la taxonomía de fugas de Valgrind y usar sus opciones de diagnóstico.
  • Configurar el sanitizer de direcciones y su detector de fugas e integrarlos en la compilación habitual.

Qué es y qué no es una fuga

La definición operativa es de alcanzabilidad, no de intención: un bloque está fugado si sigue reservado y ya no existe en el programa ningún puntero que permita llegar hasta él. La memoria está ocupada y es imposible liberarla, porque liberarla requiere una dirección que se ha perdido. Esa es la fuga en sentido estricto.

Conviene separarla de tres situaciones que se le parecen y no lo son. La primera es la retención: bloques todavía alcanzables desde una variable global o desde una caché que crece sin límite. No hay pérdida de información —podrías liberarlos— pero el consumo crece igual, y desde fuera el proceso se comporta idénticamente. La segunda es la memoria que el asignador conserva sin devolver al sistema tras un free: la residencia del proceso no baja, y aun así no hay ningún defecto. La tercera es la asignación única que vive hasta el final del programa, como una tabla de constantes inicializada al arrancar; el sistema operativo recupera todo el espacio al terminar, así que su impacto es nulo.

Lo determinante no es que exista una fuga, sino su tasa. Un bloque perdido una sola vez es irrelevante. Un bloque perdido por cada petición atendida, en un servicio que atiende millones al día, es una parada de producción programada. Por eso la pregunta correcta durante una revisión no es si el programa filtra, sino si filtra dentro de un bucle o de una ruta de manejo de errores.

#include <stdlib.h>
#include <string.h>

char *duplicar_recortado(const char *s, size_t n) {
    char *copia = malloc(n + 1);
    if (!copia) return nullptr;

    if (n == 0) return nullptr;   // FUGA: sale sin liberar copia
    memcpy(copia, s, n);
    copia[n] = '\0';
    return copia;
}

La ruta de error es donde se esconden casi todas las fugas reales, porque es la ruta que las pruebas cubren peor y la que el programador escribe con menos atención. Cada salida temprana posterior a una reserva es un punto que hay que auditar, y ese es exactamente el problema que resuelve el patrón de etiqueta única de limpieza con goto.

char *duplicar_recortado(const char *s, size_t n) {
    char *copia = nullptr;
    if (n == 0) goto salir;              // se comprueba ANTES de reservar

    copia = malloc(n + 1);
    if (!copia) goto salir;

    memcpy(copia, s, n);
    copia[n] = '\0';
salir:
    return copia;
}

La reescritura aplica dos correcciones independientes y conviene separarlas. La primera es ordenar las validaciones antes de adquirir recursos, que elimina el problema en lugar de gestionarlo. La segunda es concentrar la salida en un único punto, de modo que añadir un tercer recurso mañana no obligue a revisar cinco return dispersos. Cuando una función adquiere varios recursos, las etiquetas se escalonan y cada una deshace exactamente lo que la anterior había hecho.

Por qué C no puede evitarlas

La pregunta interesante no es por qué C tiene fugas, sino por qué el compilador no puede impedirlas. Hay tres razones y las tres son estructurales.

La primera es que el tipo de un puntero no dice nada sobre la propiedad. Un char * que apunta a un literal de cadena, a un arreglo local o a un bloque del montón es exactamente el mismo tipo, y solo el segundo debe liberarse. El lenguaje carece de vocabulario para distinguir un puntero que posee de uno que solo observa, así que la información existe únicamente en la cabeza del programador y, con suerte, en un comentario. Los lenguajes que sí evitan las fugas lo hacen añadiendo justamente eso: propiedad en el sistema de tipos y destrucción determinista al salir del ámbito.

La segunda es que la alcanzabilidad es indecidible estáticamente. Determinar si un puntero volverá a usarse equivale a decidir si una rama del programa se ejecuta, y eso remite al problema de la parada. Cualquier análisis estático de fugas es necesariamente aproximado: o avisa de cosas que no ocurren o calla sobre cosas que sí.

La tercera cierra la puerta al recolector de basura como solución retroactiva. Un recolector preciso necesita saber en cada instante qué palabras de memoria son punteros. En C puedes guardar una dirección en un uintptr_t, cifrarla con un desplazamiento, escribirla en un fichero y reconstruirla después; puedes apuntar al interior de un bloque y perder la referencia al inicio. Bajo esas reglas un recolector no puede ser preciso, solo conservador: trata cualquier palabra que parezca una dirección como si lo fuera, y por tanto puede retener basura indefinidamente. Es lo que hace el recolector de Boehm, y sus limitaciones son la demostración práctica del argumento.

ℹ️
Una fuga no es comportamiento indefinido

Merece subrayarse porque cambia la forma de priorizar. Un uso tras liberación o una escritura fuera de límites destruyen la semántica del programa: a partir de ese punto ninguna conclusión es válida. Una fuga, en cambio, deja el programa perfectamente definido; solo consume un recurso finito. Esto significa que una fuga puede convivir con un programa correcto y que su gravedad se mide en tiempo de servicio, no en corrección. También significa que un programa de vida corta y ejecución única puede permitirse ignorarlas por completo, y muchos compiladores lo hacen deliberadamente.

Valgrind: instrumentación binaria y taxonomía

Valgrind, y en concreto su herramienta memcheck, ejecuta tu binario dentro de una máquina virtual que traduce el código máquina a una representación intermedia, la instrumenta y la vuelve a compilar. No necesita que recompiles nada, funciona sobre binarios de terceros y ve todas las bibliotecas dinámicas. El precio es un factor de veinte a cincuenta en tiempo de ejecución.

Mantiene dos mapas de sombra por cada byte del programa: uno de direccionabilidad, que dice si ese byte puede tocarse legalmente, y otro de definición a nivel de bit, que dice si ese bit tiene un valor inicializado. Con ellos detecta lecturas fuera de límites, usos de valores indeterminados y liberaciones inválidas. Para las fugas hace otra cosa: intercepta cada reserva y cada liberación, y al terminar el programa recorre las raíces —registros, pila y datos globales— para clasificar cada bloque superviviente.

gcc -g -O0 fuga.c -o fuga
valgrind --leak-check=full --show-leak-kinds=all \
         --track-origins=yes --error-exitcode=1 ./fuga

La clasificación resultante tiene cuatro categorías y entenderlas ahorra horas de confusión. Definitivamente perdido es la fuga real: no queda ningún puntero al bloque. Indirectamente perdido son los bloques alcanzables solo desde otro bloque definitivamente perdido, típico de una lista enlazada cuya cabeza se filtró; se arreglan solos al arreglar la cabeza. Posiblemente perdido significa que el único puntero conocido apunta al interior del bloque y no a su inicio, algo legítimo en algunos diseños y sospechoso en la mayoría. Todavía alcanzable es la retención: hay un puntero global que llega, pero nunca se llamó a free.

El informe que produce es directamente accionable porque cada bloque perdido viene acompañado de la traza de pila del punto donde se reservó, no del punto donde se perdió. Esa distinción importa al leerlo: Valgrind te dice de dónde salió la memoria huérfana y deja a tu cargo averiguar en qué camino se soltó el último puntero.

==4711== 40 bytes in 1 blocks are definitely lost in loss record 2 of 4
==4711==    at 0x4848899: malloc (vg_replace_malloc.c:381)
==4711==    by 0x1091AE: duplicar_recortado (fuga.c:12)
==4711==    by 0x10921F: main (fuga.c:31)

La opción de origen de valores indeterminados convierte un diagnóstico inútil en uno accionable, porque además de decir dónde se leyó un valor sin inicializar indica dónde se reservó la memoria que lo contenía. Y el código de salida por error es lo que permite que una fuga rompa la integración continua en vez de quedar en un informe que nadie lee.

Queda un último instrumento imprescindible en proyectos reales: los ficheros de supresión. Muchas bibliotecas de terceros retienen memoria de forma deliberada al inicializarse, y esos avisos ahogan los tuyos. Valgrind puede generar automáticamente las reglas de supresión con la opción correspondiente y aplicarlas después, de modo que el informe quede limpio y cualquier entrada nueva sea, por construcción, culpa tuya. Un informe con ruido es un informe que nadie lee, y un informe que nadie lee no detecta nada.

Los sanitizers: instrumentación en compilación

La aproximación opuesta consiste en que sea el propio compilador quien inserte las comprobaciones. -fsanitize=address hace que GCC y Clang rodeen cada objeto con zonas rojas envenenadas, mantengan un mapa de sombra a escala de un byte por cada ocho, y pongan en cuarentena la memoria liberada para que un uso posterior se detecte en vez de reciclarse en silencio. El detector de fugas, LeakSanitizer, viaja incluido y ejecuta un rastreo de alcanzabilidad justo antes de que el proceso termine.

gcc -g -O1 -fno-omit-frame-pointer -fsanitize=address fuga.c -o fuga
ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./fuga

La diferencia de coste es de otro orden: en torno al doble de tiempo y unas tres veces la memoria, frente a las decenas de veces de Valgrind. Eso lo hace viable donde la instrumentación binaria no lo es, que es precisamente donde más falta hace: en el conjunto de pruebas completo ejecutado en cada cambio.

El detector de fugas no está obligado a esperar al final del proceso. Declarando su interfaz puedes forzar un rastreo en cualquier punto, lo que permite acotar la fuga a una fase concreta en vez de a la ejecución entera: rastreas al terminar la inicialización, ejecutas mil iteraciones del bucle de trabajo y vuelves a rastrear. Lo que aparezca entre ambas comprobaciones es la fuga que crece con la carga, que es la única que importa.

// __SANITIZE_ADDRESS__ lo define GCC; Clang define el mismo simbolo
// cuando se compila con -fsanitize=address desde la version 15.
#ifdef __SANITIZE_ADDRESS__
int __lsan_do_recoverable_leak_check(void);
#  define COMPROBAR_FUGAS() ((void)__lsan_do_recoverable_leak_check())
#else
#  define COMPROBAR_FUGAS() ((void)0)
#endif

La compilación instrumentada no es solo un modo de depuración: la práctica establecida es mantener una configuración de construcción permanente con el sanitizer activo, ejecutar en ella el conjunto completo de pruebas y tratar cualquier diagnóstico como un fallo de compilación. Detectar una fuga tres minutos después de escribirla cuesta un minuto de arreglo; detectarla en producción cuesta una guardia nocturna.

🧪

Valgrind memcheck

No exige recompilar y ve el binario entero, bibliotecas de terceros incluidas. Detecta lecturas de memoria no inicializada bit a bit, algo que el sanitizer de direcciones no hace. Lento.

AddressSanitizer

Requiere recompilar con instrumentación y con símbolos de depuración. Da trazas de pila precisas de la reserva, de la liberación y del acceso culpable. Rápido y apto para pruebas continuas.

🔍

LeakSanitizer

Incluido en el anterior y disponible por separado con -fsanitize=leak. Rastrea las raíces al salir y también bajo demanda desde el propio programa, útil para acotar una fase concreta.

🚫

Nunca a la vez

Los dos reescriben las mismas rutas de reserva y su combinación produce diagnósticos falsos o bloqueos. Se ejecutan en construcciones separadas, y ambos son incompatibles con el sanitizer de memoria.

La herramienta no encuentra la fuga: encuentra el momento en que decidiste no decidir

Hay una lectura de los informes de fugas que cambia por completo su utilidad. La traza que te devuelve Valgrind señala la línea del malloc, y la reacción instintiva es buscar dónde falta el free correspondiente y añadirlo. Esa reparación funciona y no enseña nada, porque el defecto no estaba en la línea que falta sino en un punto anterior: en el momento en que un bloque salió de una función sin que quedara escrito, en algún sitio verificable, quién quedaba obligado a destruirlo. Toda fuga es una obligación que nadie asumió porque nadie llegó a formularla. De ahí se sigue el criterio que conviene aplicar al leer cualquier informe: pregunta primero de quién era ese bloque, y solo después dónde debía liberarse. Si la respuesta a la primera pregunta no está en la firma de la función, en el nombre de la función o en el comentario que la acompaña, entonces has encontrado el defecto real y arreglar el síntoma volverá a fallar en la próxima ruta de error que alguien añada. Las herramientas son magníficas y son insustituibles, pero son un verificador, no un diseño. Detectan la ausencia de una decisión que solo tú puedes tomar, y la lección siguiente trata exactamente de cómo tomarla y cómo dejarla escrita para que el compilador, el revisor y tú mismo dentro de seis meses la puedan leer.

⚔️ Caza fugas con las dos tecnologías
  1. Escribe un programa con cuatro defectos deliberados: una fuga simple, una lista enlazada cuya cabeza se pierde, un bloque global nunca liberado y un puntero que solo conserva una dirección interior.
  2. Ejecútalo bajo Valgrind mostrando todas las categorías y comprueba que cada defecto cae en la clase que predijiste.
  3. Recompila con el sanitizer de direcciones y contrasta ambos informes: identifica qué caso detecta uno y no el otro, y explica por qué.
  4. Añade una lectura de memoria no inicializada y comprueba cuál de las dos herramientas la diagnostica con el origen de la reserva.
  5. Integra la comprobación en tu construcción: un objetivo que ejecute las pruebas bajo el sanitizer y otro bajo Valgrind con código de salida de error, de modo que una fuga rompa la compilación.