Gestión de memoria en C: malloc, free, realloc y cómo encontrar fugas con Valgrind

La gestión manual de memoria es uno de los aspectos que más distingue a C del resto de lenguajes modernos. Sin recolector de basura ni punteros inteligentes automáticos, el programador tiene control total sobre cuándo se reserva y libera memoria. Ese control tiene un precio: los errores de memoria son la fuente más habitual de bugs graves, desde crashes hasta vulnerabilidades de seguridad.

Las cuatro funciones básicas

malloc — reservar sin inicializar

malloc(n) reserva n bytes en el heap y devuelve un puntero void*. La memoria no se inicializa: contiene basura. Si no hay memoria disponible, devuelve NULL. Hay que comprobarlo siempre:

#include <stdlib.h>
#include <stdio.h>

int* numeros = malloc(10 * sizeof(int));
if (numeros == NULL) {
    fprintf(stderr, "Error: sin memorian");
    exit(EXIT_FAILURE);
}
/* Usar numeros... */
free(numeros);

En C (a diferencia de C++), el cast explícito del void* no es necesario ni recomendable: int* p = malloc(...); funciona directamente.

calloc — reservar inicializando a cero

calloc(n, size) reserva espacio para n elementos de size bytes cada uno, e inicializa toda la memoria a cero. Más seguro cuando necesitas empezar con valores limpios:

int* vector = calloc(100, sizeof(int));
/* Todos los elementos son 0 garantizado */

Internamente, calloc puede usar optimizaciones del SO (páginas cero) que lo hacen igual de rápido que malloc en muchos casos.

realloc — redimensionar un bloque

realloc(ptr, nuevo_tamaño) cambia el tamaño de un bloque previamente reservado. Puede ampliar in situ si hay espacio, o mover el bloque a otra dirección (copiando el contenido). El puntero original queda inválido tras la llamada:

int* buf = malloc(10 * sizeof(int));
/* ... más tarde necesitamos más espacio ... */
int* tmp = realloc(buf, 20 * sizeof(int));
if (tmp == NULL) {
    /* realloc falló, buf sigue siendo válido */
    free(buf);
    exit(EXIT_FAILURE);
}
buf = tmp; /* ahora buf apunta al bloque nuevo */

El patrón de la variable temporal es esencial: si realloc devuelve NULL y sobreescribimos buf directamente, perdemos el único puntero al bloque original y provocamos una fuga de memoria.

free — liberar memoria

free(ptr) devuelve la memoria al heap. Dos reglas críticas:

  • Double free: liberar el mismo puntero dos veces es comportamiento indefinido (UB). Puede corromper la estructura interna del heap.
  • Use-after-free: usar un puntero después de liberarlo también es UB. Por convención, asignar NULL al puntero tras liberarlo ayuda a detectar estos casos.
free(buf);
buf = NULL; /* buena práctica: evita use-after-free accidental */
/* free(NULL) es seguro y no hace nada */
free(NULL);

Errores comunes de memoria

Los errores más frecuentes y sus consecuencias:

  • Buffer overflow: escribir más allá del bloque reservado. Corrompe memoria adyacente.
  • Memory leak: reservar memoria y no liberarla nunca. En procesos de larga duración, agota el heap.
  • Puntero salvaje: usar un puntero no inicializado. Comportamiento completamente impredecible.
  • Use-after-free: acceder a memoria ya liberada. Puede leer datos de otra reserva posterior.

Valgrind: detectar fugas y errores

Valgrind con su herramienta memcheck es el estándar para analizar el uso de memoria en C. Compila siempre con símbolos de depuración y sin optimizaciones para obtener mensajes útiles:

gcc -g -O0 -o programa programa.c
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all ./programa

Tipos de fugas que detecta

  • definitely lost: memoria reservada sin ningún puntero apuntando a ella. Fuga real.
  • indirectly lost: memoria apuntada desde un bloque "definitely lost". Fuga en cascada.
  • still reachable: memoria con punteros aún accesibles al salir. No siempre es un error.
  • possibly lost: Valgrind no puede determinar con certeza si es fuga.

Ejemplo de salida de Valgrind

/* Código con fuga intencionada */
#include <stdlib.h>
int main(void) {
    int* p = malloc(100 * sizeof(int));
    p[0] = 1; /* uso correcto */
    /* free(p) olvidado */
    return 0;
}

Valgrind reportará algo como:

LEAK SUMMARY:
   definitely lost: 400 bytes in 1 blocks

El overhead de Valgrind es notable: entre 10x y 50x más lento que la ejecución normal. Para proyectos grandes, conviene ejecutarlo sobre casos de prueba específicos, no sobre el flujo completo.

AddressSanitizer como alternativa rápida

Si el overhead de Valgrind es inaceptable, AddressSanitizer detecta muchos de los mismos errores con solo ~2x de penalización:

gcc -g -fsanitize=address -o programa programa.c
./programa

ASan detecta heap/stack buffer overflow, use-after-free y fugas básicas. Para un análisis completo de fugas, Valgrind sigue siendo más exhaustivo. Puedes ver más sobre sanitizers en el artículo de ASan, UBSan y TSan aplicados a C++, cuyas técnicas aplican igualmente a C.

Patrones para evitar errores

/* Patrón seguro: función que reserva y siempre comprueba */
char* duplicar_cadena(const char* original) {
    size_t len = strlen(original) + 1;
    char* copia = malloc(len);
    if (copia == NULL) return NULL;
    memcpy(copia, original, len);
    return copia;
}

/* El llamador es responsable de liberar */
char* s = duplicar_cadena("hola");
if (s) {
    printf("%sn", s);
    free(s);
    s = NULL;
}

Si te interesa profundizar en depuración más allá de la memoria, el artículo sobre debugging en C con gdb y técnicas avanzadas cubre el flujo completo de diagnóstico. Y si buscas un lenguaje con gestión de memoria segura por diseño, Rust 2024 Edition ofrece una alternativa interesante.

Imagen: Pexels / Daniil Komov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP