C

C

Tutorial C: Gestión segura de memoria

Aprende a gestionar memoria en C con seguridad: verifica malloc, evita doble free y previene uso tras free para programas robustos.

Aprende C y certifícate

Verificar si malloc devuelve NULL

Cuando trabajamos con memoria dinámica en C, una de las primeras y más importantes prácticas de seguridad es verificar si la asignación de memoria fue exitosa. La función malloc() intenta reservar un bloque de memoria del tamaño solicitado, pero esta operación puede fallar por diversas razones, como falta de memoria disponible en el sistema.

Cuando malloc() no puede asignar la memoria solicitada, devuelve un puntero especial con valor NULL. Si no verificamos este caso y continuamos usando el puntero como si la asignación hubiera sido exitosa, nuestro programa podría comportarse de manera impredecible o terminar abruptamente con un error de segmentación (segmentation fault).

¿Por qué puede fallar malloc?

La asignación de memoria puede fallar por varias razones:

  • Memoria insuficiente en el sistema
  • Solicitud de bloques de memoria extremadamente grandes
  • Fragmentación excesiva del heap (montículo)

Verificación básica

La forma correcta de usar malloc() siempre incluye una verificación del valor devuelto:

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

int main() {
    int *numeros;
    
    // Intentamos reservar memoria para 5 enteros
    numeros = malloc(5 * sizeof(int));
    
    // Verificamos si malloc tuvo éxito
    if (numeros == NULL) {
        printf("Error: No se pudo asignar memoria\n");
        return 1; // Terminamos el programa con código de error
    }
    
    // Si llegamos aquí, la asignación fue exitosa
    // Podemos usar la memoria con seguridad
    for (int i = 0; i < 5; i++) {
        numeros[i] = i * 10;
    }
    
    // Mostramos los valores
    for (int i = 0; i < 5; i++) {
        printf("%d ", numeros[i]);
    }
    
    // Liberamos la memoria (esto lo veremos en otra sección)
    free(numeros);
    
    return 0;
}

Patrones de manejo de errores

Existen diferentes formas de manejar un fallo en la asignación de memoria:

  • Terminar el programa: Adecuado para errores críticos donde no es posible continuar.
if (ptr == NULL) {
    fprintf(stderr, "Error fatal: No hay memoria disponible\n");
    exit(EXIT_FAILURE);
}
  • Retornar un código de error: Útil en funciones que asignan memoria.
int* crear_array(size_t tamanio) {
    int *array = malloc(tamanio * sizeof(int));
    if (array == NULL) {
        return NULL; // La función que llama debe verificar este valor
    }
    return array;
}

// Uso:
int *datos = crear_array(1000);
if (datos == NULL) {
    printf("No se pudo crear el array\n");
    // Manejar el error apropiadamente
}
  • Reintentar con un tamaño menor: En algunas aplicaciones, puede ser aceptable reducir la cantidad de memoria solicitada.
int *buffer = NULL;
size_t tamanio = 1000000;

// Intentamos con tamaños cada vez menores
while (buffer == NULL && tamanio >= 1000) {
    buffer = malloc(tamanio * sizeof(int));
    if (buffer == NULL) {
        tamanio /= 2; // Reducimos el tamaño a la mitad
    }
}

if (buffer == NULL) {
    printf("No se pudo asignar memoria ni siquiera para el tamaño mínimo\n");
    return 1;
}

printf("Se asignó memoria para %zu elementos\n", tamanio);

Verificación en bucles y estructuras de datos

Cuando creamos estructuras de datos dinámicas como listas enlazadas o árboles, es crucial verificar cada asignación de memoria:

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

// Definimos una estructura para un nodo de lista enlazada
struct Nodo {
    int dato;
    struct Nodo *siguiente;
};

// Función para crear un nuevo nodo
struct Nodo* crear_nodo(int valor) {
    struct Nodo *nuevo_nodo = malloc(sizeof(struct Nodo));
    
    if (nuevo_nodo == NULL) {
        return NULL; // No se pudo crear el nodo
    }
    
    nuevo_nodo->dato = valor;
    nuevo_nodo->siguiente = NULL;
    return nuevo_nodo;
}

int main() {
    struct Nodo *cabeza = NULL;
    struct Nodo *actual = NULL;
    
    // Creamos una lista con 5 nodos
    for (int i = 1; i <= 5; i++) {
        struct Nodo *nuevo = crear_nodo(i * 10);
        
        if (nuevo == NULL) {
            printf("Error: No se pudo crear el nodo %d\n", i);
            
            // Aquí deberíamos liberar la memoria ya asignada
            // (lo veremos en otra sección)
            
            return 1;
        }
        
        // Añadimos el nodo a la lista
        if (cabeza == NULL) {
            cabeza = nuevo;
            actual = nuevo;
        } else {
            actual->siguiente = nuevo;
            actual = nuevo;
        }
    }
    
    // Mostramos la lista
    actual = cabeza;
    while (actual != NULL) {
        printf("%d -> ", actual->dato);
        actual = actual->siguiente;
    }
    printf("NULL\n");
    
    return 0;
}

Buenas prácticas adicionales

  • Centraliza la gestión de memoria: Crea funciones específicas para asignar y liberar memoria que incluyan verificaciones.
void* memoria_segura(size_t bytes) {
    void *ptr = malloc(bytes);
    if (ptr == NULL) {
        fprintf(stderr, "Error crítico: Sin memoria (%zu bytes)\n", bytes);
        exit(EXIT_FAILURE);
    }
    return ptr;
}

// Uso:
int *datos = memoria_segura(100 * sizeof(int));
// No necesitamos verificar NULL porque la función ya lo hace
  • Documenta el comportamiento: Especifica claramente en los comentarios o documentación cómo maneja tu código los fallos de asignación.

  • Considera usar calloc: A diferencia de malloc, calloc inicializa la memoria a cero y también requiere verificación de NULL.

int *numeros = calloc(10, sizeof(int));
if (numeros == NULL) {
    printf("Error: No se pudo asignar memoria con calloc\n");
    return 1;
}
  • Verifica incluso para asignaciones pequeñas: Aunque es menos probable que fallen las asignaciones pequeñas, siempre es buena práctica verificar el resultado de malloc.

Verificar si malloc devuelve NULL es el primer paso fundamental para escribir programas en C que manejen la memoria de forma robusta y segura. Esta simple verificación puede evitar muchos problemas difíciles de depurar y potenciales fallos en tu aplicación.

Liberar solo una vez

Uno de los errores más comunes al gestionar memoria dinámica en C es intentar liberar la misma región de memoria más de una vez. Este error, conocido como doble liberación (double free), puede causar comportamientos impredecibles y difíciles de depurar en nuestros programas.

Cuando utilizamos malloc() para reservar memoria, el sistema mantiene un registro de las áreas asignadas. Al llamar a free(), el sistema marca esa región como disponible para futuras asignaciones. Si intentamos liberar nuevamente la misma dirección, estaremos manipulando estructuras internas del sistema de gestión de memoria que ya han sido modificadas, lo que puede provocar corrupción de memoria.

Problemas de la doble liberación

La doble liberación puede causar varios problemas graves:

  • Corrupción del heap: Puede dañar las estructuras internas que el sistema utiliza para gestionar la memoria.
  • Comportamiento indefinido: El estándar de C establece que liberar memoria ya liberada produce comportamiento indefinido.
  • Fallos aleatorios: Puede causar errores que aparecen mucho después en la ejecución del programa.
  • Vulnerabilidades de seguridad: En algunos casos, puede ser explotado para ejecutar código malicioso.

Ejemplo de doble liberación

Veamos un ejemplo sencillo de este error:

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

int main() {
    int *numeros = malloc(5 * sizeof(int));
    
    if (numeros == NULL) {
        printf("Error: No se pudo asignar memoria\n");
        return 1;
    }
    
    // Usamos la memoria
    for (int i = 0; i < 5; i++) {
        numeros[i] = i + 1;
    }
    
    // Primera liberación - CORRECTA
    free(numeros);
    
    // Segunda liberación - ERROR: doble liberación
    free(numeros); // ¡NUNCA HAGAS ESTO!
    
    return 0;
}

Este programa podría:

  • Terminar abruptamente con un error
  • Continuar ejecutándose pero con el heap corrupto
  • Parecer funcionar correctamente (lo que hace el error más peligroso)

Estrategias para evitar la doble liberación

Existen varias técnicas para prevenir este problema:

  • Asignar NULL después de liberar:
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *numeros = malloc(5 * sizeof(int));
    
    if (numeros == NULL) {
        printf("Error: No se pudo asignar memoria\n");
        return 1;
    }
    
    // Trabajamos con la memoria
    for (int i = 0; i < 5; i++) {
        numeros[i] = i * 10;
    }
    
    // Liberamos y asignamos NULL inmediatamente
    free(numeros);
    numeros = NULL; // Ahora el puntero no apunta a memoria liberada
    
    // Si intentamos liberar de nuevo, será una operación segura
    // (liberar NULL no hace nada y es seguro)
    free(numeros); // Esto no causa problemas porque numeros es NULL
    
    return 0;
}
  • Encapsular la liberación en funciones:
#include <stdio.h>
#include <stdlib.h>

// Función que libera memoria de forma segura
void liberar_segura(void **ptr) {
    if (ptr != NULL && *ptr != NULL) {
        free(*ptr);
        *ptr = NULL; // Evita la posibilidad de doble liberación
    }
}

int main() {
    int *datos = malloc(10 * sizeof(int));
    
    if (datos == NULL) {
        printf("Error: No se pudo asignar memoria\n");
        return 1;
    }
    
    // Usamos la memoria
    for (int i = 0; i < 10; i++) {
        datos[i] = i;
    }
    
    // Liberamos de forma segura
    liberar_segura((void**)&datos);
    
    // Ahora datos es NULL, por lo que esta llamada es segura
    liberar_segura((void**)&datos);
    
    return 0;
}

Liberación en estructuras de datos

En estructuras de datos más complejas, como listas enlazadas, es especialmente importante tener cuidado con la liberación de memoria:

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

struct Nodo {
    int valor;
    struct Nodo *siguiente;
};

// Función para liberar toda la lista
void liberar_lista(struct Nodo **cabeza) {
    struct Nodo *actual = *cabeza;
    struct Nodo *siguiente;
    
    while (actual != NULL) {
        siguiente = actual->siguiente; // Guardamos el siguiente antes de liberar
        free(actual);                  // Liberamos el nodo actual
        actual = siguiente;            // Avanzamos al siguiente
    }
    
    *cabeza = NULL; // Importante: marcamos la cabeza como NULL
}

int main() {
    // Creamos una lista simple: 10 -> 20 -> 30
    struct Nodo *lista = malloc(sizeof(struct Nodo));
    if (lista == NULL) return 1;
    
    lista->valor = 10;
    lista->siguiente = malloc(sizeof(struct Nodo));
    if (lista->siguiente == NULL) {
        free(lista);
        return 1;
    }
    
    lista->siguiente->valor = 20;
    lista->siguiente->siguiente = malloc(sizeof(struct Nodo));
    if (lista->siguiente->siguiente == NULL) {
        free(lista->siguiente);
        free(lista);
        return 1;
    }
    
    lista->siguiente->siguiente->valor = 30;
    lista->siguiente->siguiente->siguiente = NULL;
    
    // Mostramos la lista
    struct Nodo *temp = lista;
    while (temp != NULL) {
        printf("%d -> ", temp->valor);
        temp = temp->siguiente;
    }
    printf("NULL\n");
    
    // Liberamos la lista de forma segura
    liberar_lista(&lista);
    
    // Ahora lista es NULL, por lo que es seguro intentar liberarla de nuevo
    liberar_lista(&lista); // No causa problemas
    
    return 0;
}

Casos especiales y consideraciones

  • Liberación en funciones: Cuando una función recibe un puntero y lo libera, debe comunicar claramente este comportamiento:
// Esta función libera el puntero recibido
void procesar_y_liberar(int *datos) {
    if (datos == NULL) return;
    
    // Procesamos los datos
    // ...
    
    // Liberamos la memoria
    free(datos);
    // No podemos asignar NULL aquí porque solo modificaría la copia local
}

int main() {
    int *valores = malloc(10 * sizeof(int));
    if (valores == NULL) return 1;
    
    // Llenamos el array
    for (int i = 0; i < 10; i++) {
        valores[i] = i;
    }
    
    procesar_y_liberar(valores);
    
    // IMPORTANTE: valores ahora es un puntero colgante (dangling pointer)
    // No debemos usarlo ni liberarlo de nuevo
    valores = NULL; // Marcamos como NULL para evitar usos posteriores
    
    return 0;
}
  • Liberación condicional: A veces necesitamos liberar memoria solo bajo ciertas condiciones:
int *obtener_datos(int tamanio, int inicializar) {
    int *datos = malloc(tamanio * sizeof(int));
    if (datos == NULL) return NULL;
    
    if (inicializar) {
        for (int i = 0; i < tamanio; i++) {
            datos[i] = 0;
        }
    }
    
    return datos;
}

int main() {
    int *buffer = obtener_datos(100, 1);
    if (buffer == NULL) return 1;
    
    // Procesamos los datos
    // ...
    
    // Liberamos una sola vez al final
    free(buffer);
    buffer = NULL;
    
    return 0;
}

Herramientas para detectar errores de liberación

Existen herramientas que pueden ayudarnos a detectar problemas de gestión de memoria:

  • Valgrind: Una herramienta de análisis que detecta fugas de memoria y dobles liberaciones.
  • AddressSanitizer: Una herramienta integrada en compiladores como GCC y Clang que detecta errores de memoria en tiempo de ejecución.

Para usar Valgrind:

gcc -g programa.c -o programa
valgrind --leak-check=full ./programa

Para usar AddressSanitizer:

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

Liberar la memoria solo una vez es una regla fundamental para escribir programas en C que sean robustos y fiables. Siguiendo las técnicas presentadas, podrás evitar uno de los errores más comunes y problemáticos en la gestión de memoria dinámica.

No usar memoria después de free

Cuando liberamos memoria con free() en C, el espacio es devuelto al sistema para ser reutilizado, pero el puntero sigue conteniendo la dirección de esa región de memoria. Intentar acceder o modificar esta memoria después de liberarla es uno de los errores más peligrosos en programación C y se conoce como uso de punteros colgantes (dangling pointers).

Un puntero colgante es aquel que apunta a una ubicación de memoria que ya no es válida porque ha sido liberada. Usar estos punteros puede causar comportamientos impredecibles, desde datos corruptos hasta fallos de segmentación.

¿Por qué es peligroso?

Cuando liberamos memoria con free(), ocurren varias cosas:

  • La memoria se marca como disponible para futuras asignaciones
  • El contenido original puede permanecer temporalmente, pero no hay garantía
  • El sistema puede reasignar esa memoria a otra parte del programa

Veamos un ejemplo sencillo del problema:

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

int main() {
    int *numeros = malloc(sizeof(int) * 5);
    
    if (numeros == NULL) {
        printf("Error: No se pudo asignar memoria\n");
        return 1;
    }
    
    // Inicializamos el array
    for (int i = 0; i < 5; i++) {
        numeros[i] = i + 10;
    }
    
    printf("Valores antes de liberar: %d %d %d\n", 
           numeros[0], numeros[1], numeros[2]);
    
    // Liberamos la memoria
    free(numeros);
    
    // ¡PELIGRO! Acceso a memoria liberada
    printf("Valores después de liberar: %d %d %d\n", 
           numeros[0], numeros[1], numeros[2]); // Comportamiento indefinido
    
    return 0;
}

Este código podría:

  • Mostrar los mismos valores (engañándonos al hacernos creer que todo está bien)
  • Mostrar valores aleatorios
  • Causar un error de segmentación (crash)
  • Comportarse de manera diferente en cada ejecución

Consecuencias del uso de memoria liberada

  • Corrupción de datos: Si la memoria ha sido reasignada, podríamos estar modificando datos de otra parte del programa.
  • Comportamiento impredecible: El programa puede funcionar correctamente durante pruebas y fallar en producción.
  • Fallos intermitentes: Errores que aparecen y desaparecen sin razón aparente.
  • Vulnerabilidades de seguridad: En aplicaciones críticas, puede permitir ataques de tipo use-after-free.

Cómo evitar el uso de memoria liberada

La técnica más efectiva es asignar NULL al puntero inmediatamente después de liberarlo:

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

int main() {
    int *datos = malloc(sizeof(int) * 5);
    
    if (datos == NULL) {
        printf("Error: No se pudo asignar memoria\n");
        return 1;
    }
    
    // Usamos la memoria
    for (int i = 0; i < 5; i++) {
        datos[i] = i * 5;
    }
    
    // Liberamos y asignamos NULL inmediatamente
    free(datos);
    datos = NULL;
    
    // Ahora podemos verificar si el puntero es válido antes de usarlo
    if (datos != NULL) {
        printf("Valor: %d\n", datos[0]); // Esto nunca se ejecutará
    } else {
        printf("El puntero no es válido\n");
    }
    
    return 0;
}

Patrones para manejar punteros de forma segura

  • Verificación antes de uso:
void procesar_datos(int *array) {
    if (array == NULL) {
        printf("Error: Puntero inválido\n");
        return;
    }
    
    // Procesamiento seguro
    for (int i = 0; i < 10; i++) {
        array[i] *= 2;
    }
}
  • Encapsulamiento de la gestión de memoria:
#include <stdio.h>
#include <stdlib.h>

// Estructura que encapsula el puntero y su estado
typedef struct {
    int *datos;
    int tamanio;
    int es_valido;
} ArraySeguro;

// Función para crear un array seguro
ArraySeguro crear_array(int tamanio) {
    ArraySeguro array;
    array.tamanio = tamanio;
    array.datos = malloc(sizeof(int) * tamanio);
    
    if (array.datos == NULL) {
        array.es_valido = 0;
    } else {
        array.es_valido = 1;
    }
    
    return array;
}

// Función para liberar un array seguro
void liberar_array(ArraySeguro *array) {
    if (array->es_valido && array->datos != NULL) {
        free(array->datos);
        array->datos = NULL;
        array->es_valido = 0;
    }
}

// Función para acceder de forma segura
int obtener_valor(ArraySeguro *array, int indice) {
    if (!array->es_valido || array->datos == NULL || indice < 0 || indice >= array->tamanio) {
        printf("Error: Acceso inválido\n");
        return -1; // Valor de error
    }
    
    return array->datos[indice];
}

int main() {
    ArraySeguro numeros = crear_array(5);
    
    if (!numeros.es_valido) {
        printf("Error al crear el array\n");
        return 1;
    }
    
    // Inicializamos valores
    for (int i = 0; i < numeros.tamanio; i++) {
        numeros.datos[i] = i * 10;
    }
    
    // Acceso seguro
    printf("Valor en posición 2: %d\n", obtener_valor(&numeros, 2));
    
    // Liberamos memoria
    liberar_array(&numeros);
    
    // Intento de acceso después de liberar (será detectado)
    printf("Después de liberar: %d\n", obtener_valor(&numeros, 2));
    
    return 0;
}

Errores comunes con memoria liberada

  • Olvidar asignar NULL después de free:
char *mensaje = malloc(100);
// ... usar mensaje ...
free(mensaje);
// mensaje sigue apuntando a la memoria liberada
  • Liberar memoria y seguir usándola:
int *valores = malloc(sizeof(int) * 10);
// ... inicializar valores ...
free(valores);
valores[0] = 42; // ¡ERROR! Acceso a memoria liberada
  • Retornar punteros a memoria liberada:
char* obtener_mensaje() {
    char *temp = malloc(100);
    strcpy(temp, "Hola mundo");
    
    char *resultado = temp;
    free(temp); // ¡ERROR! Liberamos la memoria pero retornamos su dirección
    
    return resultado; // Puntero colgante
}

La forma correcta sería:

char* obtener_mensaje() {
    char *temp = malloc(100);
    if (temp == NULL) return NULL;
    
    strcpy(temp, "Hola mundo");
    return temp; // La responsabilidad de liberar pasa al llamador
}

// En la función que llama:
char *msg = obtener_mensaje();
if (msg != NULL) {
    printf("%s\n", msg);
    free(msg);
    msg = NULL;
}

Detección de errores de uso después de liberar

Para detectar estos problemas durante el desarrollo, podemos usar herramientas como:

  • Valgrind: Detecta accesos a memoria liberada.
gcc -g programa.c -o programa
valgrind ./programa
  • AddressSanitizer: Integrado en compiladores modernos.
gcc -fsanitize=address programa.c -o programa
./programa

Estas herramientas pueden detectar el uso de memoria después de free() incluso cuando el programa parece funcionar correctamente.

Buenas prácticas para evitar el problema

  • Asignar NULL inmediatamente después de liberar un puntero.
  • Limitar el alcance de los punteros lo máximo posible.
  • Documentar claramente quién es responsable de liberar la memoria.
  • Usar estructuras de datos que encapsulen la gestión de memoria.
  • Implementar verificaciones antes de usar punteros.
  • Utilizar herramientas de análisis de memoria durante el desarrollo.

Evitar el uso de memoria después de liberarla es fundamental para escribir programas C robustos y seguros. Aunque requiere disciplina y atención constante, las técnicas presentadas te ayudarán a prevenir uno de los errores más comunes y difíciles de depurar en la programación C.

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

30 % DE DESCUENTO

Plan mensual

19.00 /mes

13.30 € /mes

Precio normal mensual: 19 €
63 % DE DESCUENTO

Plan anual

10.00 /mes

7.00 € /mes

Ahorras 144 € al año
Precio normal anual: 120 €
Aprende C online

Todas las lecciones de C

Accede a todas las lecciones de C y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a C y certifícate

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender la importancia de verificar si malloc devuelve NULL para evitar errores de asignación.
  • Aprender a prevenir y manejar la doble liberación de memoria para evitar corrupción y fallos.
  • Reconocer los riesgos del uso de memoria después de liberarla y cómo evitar punteros colgantes.
  • Implementar buenas prácticas y patrones seguros para la gestión de memoria dinámica en C.
  • Conocer herramientas para detectar errores comunes en la gestión de memoria como Valgrind y AddressSanitizer.