Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Memoria Dinámica

Universidad Nacional de Río Negro

Prerrequisitos: punteros, arreglos, sizeof, size_t y duración de variables.

Objetivos: 1. Reservar memoria y comprobar el resultado de malloc. 2. Asociar cada reserva exitosa con una única liberación.

Comprobación de salida: trazá la propiedad de un bloque desde malloc hasta free e identificá una fuga posible.

Memoria dinámica: propiedad y ciclo de vida

Ejemplo mínimo: reservar, usar y liberar

#include <stdlib.h>

int main(void)
{
    int *valor = malloc(sizeof *valor);
    if (valor == NULL)
        return 1;
    *valor = 42;
    free(valor);
    return 0;
}

Compilá con gcc -Wall -Wextra -std=c11 -pedantic memoria.c -o memoria. La secuencia no se puede reordenar: desreferenciar antes de comprobar NULL o usar valor después de free rompe el contrato de propiedad.

Desarrollo

Funciones de Gestión de Memoria (<stdlib.h>)

Las funciones de gestión de memoria dinámica están declaradas en el archivo de cabecera <stdlib.h>. Estas funciones permiten solicitar y liberar bloques de memoria del heap durante la ejecución del programa.

malloc (Memory Allocation)
Sintaxis
void *malloc(size_t size);
Propósito

Reserva un bloque contiguo de size bytes en el heap. La memoria reservada no está inicializada y contiene valores indeterminados (basura). Esto significa que los bytes asignados pueden contener cualquier valor que haya quedado de un uso previo de esa región de memoria.

La función retorna un puntero de tipo void *, que es un puntero genérico que puede convertirse implícitamente a cualquier tipo de puntero en C. Esto permite usar malloc para asignar memoria para cualquier tipo de dato.

Valor de Retorno
¿Por qué la memoria no está inicializada?

Por razones de eficiencia. Inicializar la memoria tiene un costo computacional, y en muchos casos el programador va a sobrescribir inmediatamente esos valores con datos útiles. Si necesitás memoria inicializada a cero, usá calloc en su lugar.

Uso Correcto

Según la 0x3001h: Siempre verificá la asignación exitosa de memoria dinámica, siempre debés verificar que la asignación de memoria fue exitosa. Además, la 0x300Bh: Usá siempre sizeof en las asignaciones de memoria dinámica, prefiriendo sizeof(*ptr) establece que debés usar sizeof para calcular el tamaño necesario en lugar de valores literales, y preferir sizeof(*puntero) sobre sizeof(tipo) para evitar errores si el tipo cambia.

La 0x3003h: No mezcles operaciones de asignación y comparación en una sola línea indica que no debés mezclar operaciones de asignación y comparación en una sola línea.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <stdlib.h>
int main()
{
    int *numeros = NULL;
    size_t cantidad = 5;
    // Asignación de memoria
    numeros = malloc(cantidad * sizeof(*numeros));
    // Verificación del resultado
    if (numeros == NULL)
    {
        fprintf(stderr, "Error: No se pudo asignar memoria.\n");
        return 1;
    }
    // Uso de la memoria
    for (size_t i = 0; i < cantidad; i++)
    {
        numeros[i] = (int)i * 10;
    }
    // Liberación de la memoria
    free(numeros);
    numeros = NULL;
    return 0;
}
calloc (Contiguous Allocation)
Sintaxis
void *calloc(size_t num_elements, size_t element_size);
Propósito

Reserva memoria para un arreglo de num_elements elementos, cada uno de tamaño element_size bytes. La diferencia fundamental con malloc es que calloc inicializa todos los bytes a cero.

El tamaño total reservado es num_elements * element_size bytes. La función realiza esta multiplicación internamente, lo que puede ser más seguro que calcularla manualmente con malloc, ya que algunas implementaciones de calloc verifican el desbordamiento (overflow) en esta multiplicación.

Ventajas
¿Cuándo usar calloc vs malloc?

Usá calloc cuando:

Usá malloc cuando:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
int main()
{
    size_t cantidad = 5;
    int *arreglo = calloc(cantidad, sizeof(*arreglo));
    if (arreglo == NULL)
    {
        fprintf(stderr, "Error: No se pudo asignar memoria.\n");
        return 1;
    }
    // Todos los elementos están inicializados en 0
    for (size_t i = 0; i < cantidad; i++)
    {
        printf("arreglo[%zu] = %d\n", i, arreglo[i]);
    }
    free(arreglo);
    arreglo = NULL;
    return 0;
}
realloc (Re-allocation)
Sintaxis
1
void *realloc(void *ptr, size_t new_size);
Propósito

Cambia el tamaño de un bloque de memoria previamente asignado.

Comportamiento
  1. Si new_size es mayor que el tamaño original:

    • Intenta expandir el bloque actual si hay espacio contiguo.

    • Si no es posible, busca un nuevo bloque de memoria lo suficientemente grande, copia el contenido del bloque antiguo al nuevo, y libera el bloque antiguo.

    • La memoria adicional no se inicializa.

  2. Si new_size es menor que el tamaño original:

    • El bloque se trunca. Los datos al final se pierden.

  3. Si new_size es 0:

    • ¡Evitar! El tratamiento de realloc(ptr, 0) cambió entre versiones del estándar y puede ser dependiente de la implementación. No lo uses como sustituto de free(); liberá con free(ptr) y asigná NULL.

Valor de Retorno
Uso Seguro

El error más común con realloc es perder la referencia al bloque original si la función falla por lo que es necesario un puntero temporal para manejar realloc de forma segura.

Incorrecto:

1
2
3
4
5
6
// ¡PELIGRO! Si realloc falla, se pierde el puntero original
ptr = realloc(ptr, nuevo_tamano);
if (ptr == NULL)
{
    // Fuga de memoria: el bloque original se perdió
}

Correcto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdlib.h>
int *numeros = malloc(5 * sizeof(*numeros));
if (numeros == NULL)
{
    fprintf(stderr, "Error: No se pudo asignar memoria inicial.\n");
    return 1;
}
// ...
size_t nuevo_tamano = 10;
int *temp = realloc(numeros, nuevo_tamano * sizeof(*temp));
if (temp == NULL)
{
    // realloc falló, pero 'numeros' sigue siendo válido
    fprintf(stderr, "Error: No se pudo redimensionar la memoria.\n");
    free(numeros); // Liberar el bloque original
    numeros = NULL;
    return 1;
}
// Éxito: ahora 'numeros' puede apuntar al nuevo bloque
numeros = temp;
free (Liberación)
Sintaxis
1
void free(void *ptr);
Propósito

Libera un bloque de memoria previamente reservado y lo devuelve al allocator del proceso para que pueda ser reutilizado. El allocator puede, o no, devolver eventualmente páginas al sistema operativo.

Reglas Fundamentales

Según la 0x3002h: Liberá siempre la memoria dinámica y asigná NULL al puntero para mitigar punteros colgantes, debés:

  1. Liberar siempre la memoria dinámica que asignaste.

  2. Asignar NULL a la variable puntero inmediatamente después de liberarlo para evitar reutilizar esa variable. Esto no invalida otros aliases.

Es seguro llamar a free(NULL), la función simplemente no hace nada.

1
2
free(ptr);
ptr = NULL; // Previene el uso accidental del puntero colgante
El Allocator: Gestión Interna del Heap

Cuando llamás a malloc o calloc, no estás interactuando directamente con el sistema operativo en cada llamada. En cambio, estas funciones son parte de un subsistema llamado allocator (asignador de memoria) que gestiona el heap de tu proceso.

¿Cómo funciona el allocator?

El allocator mantiene su propia estructura de datos para rastrear qué bloques del heap están libres y cuáles están ocupados. Existen varias estrategias de implementación, pero todas deben resolver dos problemas fundamentales:

  1. Al asignar: ¿Qué bloque libre usar cuando hay varios disponibles?

  2. Al liberar: ¿Cómo marcar el bloque como libre y potencialmente fusionarlo con bloques adyacentes?

Estructura típica de un bloque de memoria:

Estructura de un bloque de memoria en el heap, mostrando el header con metadata,
el área de datos del usuario, y el footer opcional.

Figure 1:Estructura de un bloque de memoria en el heap, mostrando el header con metadata, el área de datos del usuario, y el footer opcional.

El header típicamente contiene:

Estrategias de asignación:

  1. First Fit (Primer ajuste):

    • Busca desde el inicio del heap hasta encontrar el primer bloque libre suficientemente grande.

    • Ventaja: Rápido (termina apenas encuentra un bloque).

    • Desventaja: Tiende a fragmentar la parte inicial del heap.

  2. Best Fit (Mejor ajuste):

    • Busca en todo el heap el bloque libre más pequeño que satisfaga la solicitud.

    • Ventaja: Minimiza el desperdicio de memoria.

    • Desventaja: Lento (debe recorrer toda la lista) y crea muchos bloques diminutos inutilizables.

  3. Next Fit (Siguiente ajuste):

    • Como First Fit, pero continúa desde donde terminó la última búsqueda.

    • Ventaja: Distribuye mejor las asignaciones por todo el heap.

    • Desventaja: Aún puede fragmentar.

  4. Segregated Free Lists (Listas libres segregadas):

    • Mantiene listas separadas para bloques de diferentes tamaños.

    • Ventaja: Muy eficiente para patrones de asignación predecibles.

    • Desventaja: Más complejo de implementar y mantener.

Interacción con el sistema operativo:

El allocator solicita memoria al sistema operativo en grandes cantidades (típicamente mediante sbrk() o mmap() en Unix/Linux) y luego la subdivide según las necesidades del programa. Esto reduce enormemente el número de llamadas al sistema, que son costosas.

Flujo de interacción entre el programa, las funciones de memoria
(malloc/calloc/free), el allocator interno que mantiene un pool de memoria, y
ocasionalmente el sistema operativo que proporciona acceso a la RAM física.

Figure 2:Flujo de interacción entre el programa, las funciones de memoria (malloc/calloc/free), el allocator interno que mantiene un pool de memoria, y ocasionalmente el sistema operativo que proporciona acceso a la RAM física.

Coalescing (Fusión de bloques):

Cuando liberás un bloque con free(), el allocator intenta fusionarlo con bloques libres adyacentes para crear bloques más grandes. Esto ayuda a combatir la fragmentación externa.

Proceso de coalescing (fusión) donde bloques libres adyacentes (LIBRE-B y
LIBRE-C) se combinan en un único bloque más grande (LIBRE-BC fusionado).

Figure 3:Proceso de coalescing (fusión) donde bloques libres adyacentes (LIBRE-B y LIBRE-C) se combinan en un único bloque más grande (LIBRE-BC fusionado).

Un pool como alternativa al patrón de asignación: un programa que crea miles de objetos pequeños paga una llamada y metadatos por objeto. Si el tamaño es conocido de antemano, un bloque contiguo o un pool puede reducir esa sobrecarga. Un pool no reemplaza free: sus elementos se devuelven al pool y el bloque completo se libera una sola vez al destruirlo. Mezclar punteros de un pool con free() directo es un error.

Para comparar estrategias medí tiempo, memoria residente y fallos de caché; una implementación puede cambiar entre versiones de la libc. Documentá libc, sistema operativo, arquitectura y flags antes de generalizar resultados:

valgrind --tool=massif ./programa   # perfil de uso de heap en el tiempo
malloc_stats();                      # glibc: resumen por stderr en tiempo de ejecución

malloc_stats y mallinfo son extensiones de glibc, no funciones de C estándar: usarlas ata el código a esa biblioteca.

Mini-ejercicio

Reservá 100 000 bloques de 8 bytes con malloc y medí el tiempo total. Repetí reservando un único bloque de 800 000 bytes e indexando manualmente. ¿A qué atribuís la diferencia: al algoritmo, a la metadata por bloque, o a ambos? Proponé una medición que los distinga.

Ejercicios de Autoevaluación (Funciones de Gestión)
  1. Reservá espacio para n enteros, inicializalos en cero y liberá el arreglo. ¿Qué validaciones son necesarias antes de multiplicar n * sizeof(int)?

  2. Ampliá un arreglo existente sin perder el puntero original si realloc falla.

Solución orientativa
#include <stdint.h>
#include <stdlib.h>

size_t bytes;
if (n == 0 || n > SIZE_MAX / sizeof(int))
    return NULL;
bytes = n * sizeof(int);

int *tmp = realloc(*datos, bytes);
if (tmp == NULL)
    return NULL;                 /* *datos sigue siendo válido */
*datos = tmp;
return tmp;

La variable temporal evita perder la dirección original. calloc puede usarse cuando se requiere inicialización a cero, pero nunca reemplaza la comprobación de overflow ni la de NULL.

Errores Comunes y Peligros

La gestión manual de memoria es una fuente frecuente de errores en C. Comprender estos errores y cómo prevenirlos es fundamental para escribir código robusto.

Memory Leak (Fuga de Memoria)

Una fuga de memoria ocurre cuando se pierde la referencia a un bloque de memoria reservado sin haberlo liberado con free. La memoria queda inutilizable para el programa hasta que este termina.

Ejemplo Problemático
1
2
3
4
5
6
7
8
#include <stdlib.h>
void funcion_con_fuga()
{
    int *datos = malloc(100 * sizeof(*datos));
    // Se realizan operaciones...
    // ERROR: La función termina sin liberar 'datos'
    // El bloque de memoria se pierde
}
Solución

Asegurate de que cada asignación tenga su correspondiente liberación, siguiendo la 0x3002h: Liberá siempre la memoria dinámica y asigná NULL al puntero para mitigar punteros colgantes.

1
2
3
4
5
6
7
8
9
10
11
void funcion_sin_fuga()
{
    int *datos = malloc(100 * sizeof(*datos));
    if (datos == NULL)
    {
        return;
    }
    // Operaciones...
    free(datos);
    datos = NULL;
}
Dangling Pointer (Puntero Colgante)

Un puntero colgante es un puntero que apunta a una dirección de memoria que ya ha sido liberada con free. Intentar acceder a través de él produce comportamiento indefinido.

Ejemplo Problemático
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>
int main()
{
    int *ptr = malloc(sizeof(*ptr));
    if (ptr == NULL)
    {
        return 1;
    }
    *ptr = 42;
    free(ptr);
    ptr = NULL;           // Previene el uso del puntero colgante
    printf("%d\n", *ptr); // Comportamiento indefinido
    return 0;
}
Solución

Asigná NULL al puntero inmediatamente después de llamar a free, como exige la 0x3002h: Liberá siempre la memoria dinámica y asigná NULL al puntero para mitigar punteros colgantes.

1
2
3
4
5
6
7
8
9
10
int main()
{
    int *ptr = malloc(sizeof(*ptr));
    *ptr = 42;
    free(ptr);
    ptr = NULL; // Previene el uso del puntero colgante
    // Intentar desreferenciar ptr ahora causará un error inmediato
    // en lugar de comportamiento indefinido silencioso
    return 0;
}
Double Free (Doble Liberación)

Intentar liberar el mismo bloque de memoria dos veces causa comportamiento indefinido y puede corromper la gestión de memoria del heap.

Ejemplo Problemático
1
2
3
4
5
6
7
8
#include <stdlib.h>
int main()
{
    int *ptr = malloc(sizeof(*ptr));
    free(ptr);
    free(ptr); // ERROR: Doble liberación
    return 0;
}
Solución

Asignar NULL después de cada free previene este problema, ya que free(NULL) es una operación segura que no hace nada.

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
    int *ptr = malloc(sizeof(*ptr));
    if (ptr == NULL)
    {
        return 1;
    }
    free(ptr);
    ptr = NULL;
    free(ptr); // Seguro: free(NULL) no hace nada
    return 0;
}
Acceso Fuera de Límites

Leer o escribir fuera de los límites del bloque de memoria reservado corrompe datos adyacentes y causa comportamiento impredecible.

Ejemplo Problemático
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdlib.h>
int main()
{
    int *arreglo = malloc(5 * sizeof(*arreglo));
    if (arreglo == NULL)
    {
        return 1;
    }
    // ERROR: Acceso fuera de límites
    for (size_t i = 0; i <= 5; i++) // Debería ser i < 5
    {
        arreglo[i] = (int)i;
    }
    free(arreglo);
    arreglo = NULL;
    return 0;
}
Solución

La 0x300Ch: Verificá siempre los límites de los arreglos antes de acceder a sus elementos exige verificar siempre los límites de los arreglos antes de acceder a sus elementos. La 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t establece que las variables que representan tamaños o índices de arreglos deben ser de tipo size_t.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
    size_t tamano = 5;
    int *arreglo = malloc(tamano * sizeof(*arreglo));
    if (arreglo == NULL)
    {
        return 1;
    }
    // Correcto: i < tamano previene el acceso fuera de límites
    for (size_t i = 0; i < tamano; i++)
    {
        arreglo[i] = (int)i;
    }
    free(arreglo);
    arreglo = NULL;
    return 0;
}
Uso de Memoria Después de free

Acceder a memoria después de liberarla es un error similar al puntero colgante.

Ejemplo Problemático
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <stdlib.h>
int main()
{
    int *ptr = malloc(sizeof(*ptr));
    *ptr = 100;
    free(ptr);
    // ERROR: Uso de memoria liberada
    printf("%d\n", *ptr);
    return 0;
}
Solución

Asegurate de no usar el puntero después de liberarlo, y asigná NULL para detectar errores fácilmente.

Ejercicios de Autoevaluación (Errores Comunes y Peligros)

Clasificá cada caso como fuga, uso después de liberar, doble liberación o acceso fuera de límites, y proponé una corrección:

int *p = malloc(4 * sizeof *p);
p[4] = 10;
free(p);
printf("%d\n", p[0]);
free(p);
Solución

p[4] escribe fuera del arreglo (los índices válidos son 0..3); el printf usa un puntero colgante y el segundo free es una doble liberación. La versión segura comprueba la capacidad, libera una sola vez y evita volver a usar el puntero:

if (p != NULL) {
    p[3] = 10;
    free(p);
    p = NULL;
}

Poner p en NULL no repara copias del puntero que existan en otras variables; la propiedad del bloque debe estar clara en el diseño.

Seguridad de Memoria: Una Perspectiva Profunda

La seguridad de memoria (memory safety) es uno de los desafíos más importantes en programación de sistemas. Comprender por qué los errores de memoria son tan peligrosos requiere entender qué significa “comportamiento indefinido” y cómo puede ser explotado.

Comportamiento Indefinido (Undefined Behavior)

Cuando el estándar de C dice que una operación tiene “comportamiento indefinido” (UB), significa que absolutamente cualquier cosa puede pasar. El compilador no está obligado a hacer nada razonable.

¿Por qué existe el UB?

El comportamiento indefinido existe por dos razones principales:

  1. Rendimiento: Verificar todos los accesos a memoria en tiempo de ejecución sería prohibitivamente lento. C delega la responsabilidad al programador para mantener la máxima velocidad.

  2. Flexibilidad del compilador: El compilador puede hacer optimizaciones agresivas asumiendo que tu código no tiene UB. Si tenés UB, esas optimizaciones pueden hacer que tu programa haga cosas completamente inesperadas.

Ejemplos de UB en gestión de memoria:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// UB #1: Desreferenciar puntero NULL
int *ptr = NULL;
*ptr = 42; // Crash probable, pero no garantizado
// UB #2: Uso después de free
int *ptr = malloc(sizeof(int));
free(ptr);
*ptr = 42; // Puede parecer funcionar, pero es UB
// UB #3: Doble free
free(ptr);
free(ptr); // Puede corromper el heap
// UB #4: Acceso fuera de límites
int arr[10];
arr[15] = 42; // Puede sobrescribir otras variables
// UB #5: Retornar dirección de variable local
int *funcion()
{
    int x = 42;
    return &x; // x desaparece al retornar
}

Consecuencias del UB:

El comportamiento indefinido no solo causa crashes. Puede:

  1. Parecer funcionar: El programa parece correr bien en tu máquina, pero falla en producción.

  2. Funcionar hasta que cambies algo no relacionado: Agregar una línea de código en otro lado hace que el programa crashee, porque cambió el layout de memoria.

  3. Ser explotado por atacantes: Los buffer overflows son la base de muchas vulnerabilidades de seguridad.

  4. Ser “optimizado” por el compilador de forma sorprendente:

:linenos: // El programador escribe: if (ptr != NULL) { *ptr = 42; } // Pero si el compilador ve *ptr antes del if, // puede asumir que ptr nunca es NULL (porque desreferenciarlo // cuando es NULL sería UB), y eliminar la verificación.


(vulnerabilidades-comunes)=
#### Vulnerabilidades Comunes

Los errores de memoria no son solo bugs: son vulnerabilidades de seguridad.
Comprender los ataques comunes te ayuda a escribir código más defensivo.

**Buffer Overflow:**

Un buffer overflow ocurre cuando escribís más datos de los que un buffer puede
contener, sobrescribiendo memoria adyacente.

:::{code-block}c
:linenos:
#include <string.h>

/* Ejemplo deliberadamente vulnerable: no usar en producción. */
void vulnerable(const char *entrada)
{
 char buffer[10];
 // Solo como demostración de un error: entrada puede superar la capacidad.
 strcpy(buffer, entrada);
}

:::
<!-- {code-block}c -->

En el stack, un atacante puede sobrescribir la dirección de retorno para
ejecutar código arbitrario:

:::{figure} 5/buffer_overflow.svg
:name: fig-buffer-overflow
:width: 100%

Visualización de buffer overflow en el stack: antes del overflow el buffer tiene
su espacio asignado y la dirección de retorno está protegida; después del
overflow, datos excesivos (representados como 'A') sobrescriben el buffer, los
datos intermedios, y finalmente corrompen la dirección de retorno, permitiendo
potencialmente la ejecución de código malicioso.

:::
<!-- {figure} 5/buffer_overflow.svg -->
<!-- {figure} 1/buffer_overflow.svg -->

**Use-After-Free (UAF):**

Usar memoria después de liberarla puede permitir que un atacante controle datos
críticos:

:::{code-block}c
:linenos:
/* Pseudocódigo de una UAF: los identificadores representan datos externos. */
const char *datos_del_atacante = "entrada externa";
struct usuario
{
 char nombre[50];
 int es_admin;
};
struct usuario *usr = malloc(sizeof(*usr));
usr->es_admin = 0; // Usuario normal
free(usr);
// ... código intermedio ...
// Otro código asigna memoria que reutiliza el mismo espacio:
char *buffer = malloc(100);
strcpy(buffer, datos_del_atacante);
// Ahora usr apunta a memoria controlada por el atacante:
if (usr->es_admin)
{ // ⚠️ UAF: usa memoria liberada
 // El atacante pudo sobrescribir es_admin a 1
 dar_privilegios_admin();
}

:::
<!-- {code-block}c -->

**Double Free:**

Liberar memoria dos veces puede corromper las estructuras internas del
allocator, permitiendo ataques sofisticados:

:::{code-block}c
:linenos:
free(ptr);
// ... código ...
free(ptr); // Corrompe la lista de bloques libres
// Asignaciones posteriores pueden retornar direcciones sobrepuestas:
int *a = malloc(100);
int *b = malloc(100);
// Ahora 'a' y 'b' podrían apuntar a la misma memoria!

:::
<!-- {code-block}c -->

(estrategias-defensivas)=
#### Estrategias Defensivas

**1. Principio de mínimo privilegio:** No uses más memoria de la que necesitás,
y no la mantengas asignada más tiempo del necesario.

**2. Verificación exhaustiva:**

:::{code-block}c
:linenos:
// No solo verificar malloc:
if (ptr == NULL)
{ /* error */
}
// También verificar límites:
if (indice >= tamano)
{ /* error */
}
// Y validar punteros recibidos:
if (ptr_entrada == NULL)
{ /* error */
}

:::
<!-- {code-block}c -->

**3. Inicialización defensiva:**

:::{code-block}c
:linenos:
// Inicializar punteros:
int *ptr = NULL;
// Después de free, anular:
free(ptr);
ptr = NULL;
// Inicializar estructuras completamente:
struct datos d = {0}; // Todos los campos en cero

:::
<!-- {code-block}c -->

**4. Encapsulación:** Ocultá la gestión de memoria detrás de funciones:

:::{code-block}c
:linenos:
// En lugar de exponer punteros directamente:
recurso_t *crear_recurso(void);
void usar_recurso(recurso_t *r);
void destruir_recurso(recurso_t *r);
// Los usuarios nunca ven malloc/free directamente

:::
<!-- {code-block}c -->

**5. Usar funciones seguras:**

:::{code-block}c
:linenos:
#include <stdio.h>

/* La capacidad debe viajar junto con el buffer. */
int copiar_texto(char *destino, size_t capacidad, const char *origen)
{
 if (destino == NULL || origen == NULL || capacidad == 0)
     return 0;
 int escrito = snprintf(destino, capacidad, "%s", origen);
 return escrito >= 0 && (size_t)escrito < capacidad;
}

:::
<!-- {code-block}c -->

:::{important} La Seguridad No Es Opcional

En el desarrollo profesional, los errores de memoria no son solo bugs molestos:
son **vulnerabilidades de seguridad** que pueden ser explotadas. Cada uno de los
errores discutidos (buffer overflow, UAF, double free) ha sido la base de
ataques reales en sistemas de producción.

Escribir código memory-safe no es solo seguir buenas prácticas: es una
responsabilidad ética. Tu código podría procesar datos sensibles, ejecutarse en
infraestructura crítica, o estar expuesto a atacantes motivados. La seguridad
debe ser parte del diseño desde el principio, no un añadido posterior.

:::
<!-- {important} La Seguridad No Es Opcional -->

(ejercicios-de-autoevaluacion-seguridad-de-memoria)=
#### Ejercicios de Autoevaluación (Seguridad de Memoria)

El siguiente código procesa una entrada externa. Señalá dos riesgos y
reescribilo suponiendo que `dest` tiene capacidad `cap`:

```c
void copiar(const char *entrada, char *dest, size_t cap)
{
 strcpy(dest, entrada);
}
Solución

El riesgo principal es el desbordamiento de dest; además, la entrada puede no ser un puntero válido o puede no contener un \\0. La interfaz debe documentar la precondición sobre ambos punteros y limitar la operación:

int copiar(const char *entrada, char *dest, size_t cap)
{
    int escritos;
    if (entrada == NULL || dest == NULL || cap == 0)
        return 0;
    escritos = snprintf(dest, cap, "%s", entrada);
    return escritos >= 0 && (size_t)escritos < cap;
}

En código real conviene validar también el origen antes de copiar; ninguna función puede hacer segura una dirección ya inválida.

Ejemplo Integrador: Arreglo Dinámico de Tamaño Fijo

Este ejemplo demuestra cómo aplicar las buenas prácticas de gestión de memoria en un caso realista: una estructura que encapsula un arreglo dinámico de enteros de tamaño fijo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#define ERROR_MEMORIA -1
#define ERROR_INDICE -2
#define EXITO 0
typedef struct
{
    int *datos;
    size_t tamano;
} arreglo_t;
/**
 * Crea un nuevo arreglo dinámico de tamaño fijo.
 * @param tamano El tamaño del arreglo (debe ser mayor que 0).
 * @returns Un puntero al arreglo creado. El llamador es responsable
 *          de liberar esta memoria con destruir_arreglo().
 *          Retorna NULL si no hay memoria disponible o si tamano es 0.
 * @post El arreglo está inicializado con todos sus elementos en 0.
 */
arreglo_t *crear_arreglo(size_t tamano)
{
    if (tamano == 0)
    {
        return NULL;
    }
    arreglo_t *arreglo = malloc(sizeof(*arreglo));
    if (arreglo == NULL)
    {
        return NULL;
    }
    arreglo->datos = calloc(tamano, sizeof(*(arreglo->datos)));
    if (arreglo->datos == NULL)
    {
        free(arreglo);
        return NULL;
    }
    arreglo->tamano = tamano;
    return arreglo;
}
/**
 * Establece el valor de un elemento en el arreglo.
 * @param arreglo Puntero al arreglo (no debe ser NULL).
 * @param indice Índice del elemento a modificar.
 * @param valor Nuevo valor para el elemento.
 * @pre arreglo no debe ser NULL.
 * @pre indice debe ser menor que el tamaño del arreglo.
 * @returns EXITO si el valor se estableció correctamente,
 *          ERROR_INDICE si el índice es inválido.
 */
int establecer_elemento(arreglo_t *arreglo, size_t indice, int valor)
{
    if (arreglo == NULL)
    {
        return ERROR_MEMORIA;
    }
    if (indice >= arreglo->tamano)
    {
        return ERROR_INDICE;
    }
    arreglo->datos[indice] = valor;
    return EXITO;
}
/**
 * Obtiene el valor de un elemento del arreglo.
 * @param arreglo Puntero al arreglo (no debe ser NULL).
 * @param indice Índice del elemento a obtener.
 * @param valor_out Puntero donde se almacenará el valor (no debe ser NULL).
 * @pre arreglo y valor_out no deben ser NULL.
 * @pre indice debe ser menor que el tamaño del arreglo.
 * @returns true si se obtuvo el elemento, false si algún parámetro es
 * inválido.
 */
bool obtener_elemento(const arreglo_t *arreglo, size_t indice, int *valor_out)
{
    if (arreglo == NULL || valor_out == NULL)
    {
        return false;
    }
    if (indice >= arreglo->tamano)
    {
        return false;
    }
    *valor_out = arreglo->datos[indice];
    return true;
}
/**
 * Calcula la suma de todos los elementos del arreglo.
 * @param arreglo Puntero al arreglo (no debe ser NULL).
 * @pre arreglo no debe ser NULL.
 * @returns La suma de todos los elementos, o 0 si el arreglo es NULL.
 */
int sumar_elementos(const arreglo_t *arreglo)
{
    if (arreglo == NULL)
    {
        return 0;
    }
    int suma = 0;
    for (size_t i = 0; i < arreglo->tamano; i++)
    {
        suma = suma + arreglo->datos[i];
    }
    return suma;
}
/**
 * Imprime todos los elementos del arreglo.
 * @param arreglo Puntero al arreglo (no debe ser NULL).
 * @pre arreglo no debe ser NULL.
 */
void imprimir_arreglo(const arreglo_t *arreglo)
{
    if (arreglo == NULL)
    {
        return;
    }
    printf("Arreglo [tamaño: %zu]: [", arreglo->tamano);
    for (size_t i = 0; i < arreglo->tamano; i++)
    {
        printf("%d", arreglo->datos[i]);
        if (i < arreglo->tamano - 1)
        {
            printf(", ");
        }
    }
    printf("]\n");
}
/**
 * Destruye un arreglo y libera toda la memoria asociada.
 * @param arreglo Doble puntero al arreglo a destruir.
 * @post Toda la memoria del arreglo es liberada y el puntero se anula.
 */
void destruir_arreglo(arreglo_t **arreglo)
{
    if (arreglo != NULL && *arreglo != NULL)
    {
        if ((*arreglo)->datos != NULL)
        {
            free((*arreglo)->datos);
            (*arreglo)->datos = NULL;
        }
        free(*arreglo);
        *arreglo = NULL;
    }
}
int main()
{
    size_t tamano = 8;
    arreglo_t *mi_arreglo = crear_arreglo(tamano);
    if (mi_arreglo == NULL)
    {
        fprintf(stderr, "Error: No se pudo crear el arreglo.\n");
        return 1;
    }
    // Establecer algunos valores
    for (size_t i = 0; i < tamano; i++)
    {
        if (establecer_elemento(mi_arreglo, i, (int)(i * i)) != EXITO)
        {
            fprintf(stderr, "Error: No se pudo establecer elemento.\n");
            destruir_arreglo(mi_arreglo);
            return 1;
        }
    }
    imprimir_arreglo(mi_arreglo);
    // Obtener y mostrar un elemento específico
    int valor = 0;
    if (obtener_elemento(mi_arreglo, 3, &valor))
    {
        printf("Elemento en índice 3: %d\n", valor);
    }
    // Calcular la suma
    int suma = sumar_elementos(mi_arreglo);
    printf("Suma de todos los elementos: %d\n", suma);
    // Liberar recursos
    destruir_arreglo(&mi_arreglo);
    return 0;
}

Conceptos Avanzados y Rendimiento de Bajo Nivel

En esta sección se presentan detalles técnicos complementarios sobre la ejecución y la jerarquía de hardware, orientados a comprender el rendimiento real de los programas.

Funcionamiento de la Pila en Ensamblador (x86-64)

A nivel de arquitectura de hardware, la pila se gestiona a través de registros del procesador. En la arquitectura x86-64:

A continuación se muestra cómo se ve un prólogo y epílogo típico de una función en código ensamblador simplificado:

1
2
3
4
5
6
7
8
9
10
funcion:
    push rbp              ; Guardar frame pointer anterior
    mov rbp, rsp          ; Establecer nuevo frame pointer
    sub rsp, 16           ; Reservar espacio para variables locales

    ; ... cuerpo de la función ...

    mov rsp, rbp          ; Restaurar stack pointer
    pop rbp               ; Restaurar frame pointer anterior
    ret                   ; Retornar
Ejercicios de Autoevaluación (Conceptos Avanzados y Bajo Nivel)

Observá:

int *crear(void)
{
    int local = 42;
    return &local;
}

Explicá qué ocurre al usar el resultado y proponé dos alternativas válidas.

Solución

Al retornar termina la duración de local; el puntero queda colgante y desreferenciarlo produce comportamiento indefinido. Se puede devolver un valor por copia (int crear(void) { return 42; }) o reservar almacenamiento dinámico y transferir explícitamente su propiedad:

int *crear(void)
{
    int *p = malloc(sizeof *p);
    if (p != NULL) *p = 42;
    return p;                    /* quien recibe debe llamar a free */
}

Conexión con el Siguiente Tema

Con memoria dinámica dominada, tenemos las herramientas para implementar cualquier estructura de datos. Pero antes de construir estructuras complejas, necesitamos entender cómo medir su eficiencia: ¿cuánto tiempo toma buscar un elemento? ¿Cómo crece el tiempo de ejecución al duplicar el tamaño de entrada?

El apunte Análisis de Complejidad Algorítmica introduce el análisis asintótico de algoritmos:

El análisis de complejidad es fundamental para tomar decisiones informadas: ¿vale la pena usar una lista enlazada (memoria dinámica, O(n)O(n) búsqueda) o un arreglo redimensionable (overhead de copia, O(1)O(1) acceso)? Sin complejidad, solo podemos intuir; con ella, podemos demostrar matemáticamente qué solución es mejor.

Después, el apunte Tipos de Datos Abstractos, Pilas y Colas muestra cómo encapsular estructuras con memoria dinámica en Tipos Abstractos de Datos, ocultando detalles de implementación y exponiendo interfaces limpias.

Pregunta puente: Una búsqueda lineal en lista enlazada toma O(n)O(n) tiempo. ¿Podemos hacer mejor? ¿Cómo cuantificamos “mejor”? La respuesta requiere análisis formal de complejidad algorítmica.

Ejercicios de Autoevaluación

Glosario

Stack (Pila)
Región de memoria que se gestiona automáticamente bajo el orden LIFO para variables locales y llamadas a funciones.
Heap (Montículo)
Región de memoria para asignación dinámica que el programador gestiona manualmente en tiempo de ejecución.
Memory Leak (Fuga de memoria)
Pérdida de la referencia a memoria dinámica sin liberarla.
Dangling Pointer (Puntero colgante)
Puntero que apunta a una dirección ya liberada.
Double Free (Doble liberación)
Intento de liberar la misma dirección de memoria más de una vez.

Síntesis y Resumen

Resumen de Buenas Prácticas

La gestión segura de memoria dinámica requiere disciplina y adherencia a un conjunto de prácticas probadas. Este resumen consolida las reglas fundamentales.

Inicializar Punteros

Siempre inicializá los punteros a NULL al declararlos si no tenés una dirección válida para asignarles inmediatamente. Esto está codificado en la 0x7001h: Siempre debés inicializar las variables a un valor conocido y la 0x3008h: Los punteros nulos deben ser inicializados y comparados con NULL, no con 0.

1
int *ptr = NULL;
Verificar Asignaciones

Siempre comprobá si el valor devuelto por malloc o calloc es NULL antes de usar el puntero. La 0x3001h: Siempre verificá la asignación exitosa de memoria dinámica lo exige explícitamente.

1
2
3
4
5
6
7
ptr = malloc(tamano);
if (ptr == NULL)
{
    // Manejo de error
    fprintf(stderr, "Error: No se pudo asignar memoria.\n");
    return ERROR_MEMORIA;
}
Liberar Memoria

Por cada asignación exitosa con malloc o calloc, debe haber una llamada correspondiente a free. La 0x3002h: Liberá siempre la memoria dinámica y asigná NULL al puntero para mitigar punteros colgantes establece esta simetría como obligatoria.

1
free(ptr);
Anular Punteros Después de Liberar

Después de llamar a free(puntero), asigná puntero = NULL para evitar punteros colgantes. La 0x3002h: Liberá siempre la memoria dinámica y asigná NULL al puntero para mitigar punteros colgantes lo exige.

1
2
free(ptr);
ptr = NULL;
Mantener Simetría

Intentá que la función que reserva la memoria sea también responsable de liberarla, o que haya una correspondencia clara, como crear_estructura() y destruir_estructura(). Esta práctica está documentada en la 0x3002h: Liberá siempre la memoria dinámica y asigná NULL al puntero para mitigar punteros colgantes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
recurso_t *crear_recurso()
{
    recurso_t *r = malloc(sizeof(*r));
    if (r == NULL)
    {
        return NULL;
    }
    // Inicialización...
    return r;
}
void destruir_recurso(recurso_t *r)
{
    if (r != NULL)
    {
        // Liberación de recursos internos...
        free(r);
    }
}
Documentar Propiedad

La 0x3006h: Documentá la propiedad de los recursos al utilizar punteros exige que documentes claramente quién es el responsable de liberar la memoria cuando una función recibe o devuelve un puntero a memoria dinámica.

1
2
3
4
5
6
7
8
/**
 * Crea un nuevo nodo de lista.
 * @param valor El valor a almacenar en el nodo.
 * @returns Un puntero al nuevo nodo. El llamador es responsable
 *          de liberar esta memoria con destruir_nodo().
 *          Retorna NULL si no hay memoria disponible.
 */
nodo_t *crear_nodo(int valor);
Usar const Apropiadamente

Según la 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique, los argumentos de tipo puntero deben ser const siempre que la función no los modifique. Esto establece un contrato claro y permite al compilador detectar modificaciones no intencionales.

1
2
3
4
5
6
7
8
void imprimir_arreglo(const int *arreglo, size_t tamano)
{
    for (size_t i = 0; i < tamano; i++)
    {
        printf("%d ", arreglo[i]);
    }
    printf("\n");
}
Usar sizeof Correctamente

La 0x300Bh: Usá siempre sizeof en las asignaciones de memoria dinámica, prefiriendo sizeof(*ptr) establece que debés usar siempre sizeof en las asignaciones de memoria dinámica, y preferir sizeof(*puntero) sobre sizeof(tipo).

1
2
3
4
// Preferido
int *ptr = malloc(n * sizeof(*ptr));
// Evitar
int *ptr = malloc(n * sizeof(int)); // Si el tipo de ptr cambia, esto falla
Usar size_t para Tamaños e Índices

La 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t exige que las variables que representan tamaños o índices de arreglos sean de tipo size_t.

1
2
3
4
5
6
size_t tamano = 10;
int *arreglo = malloc(tamano * sizeof(*arreglo));
for (size_t i = 0; i < tamano; i++)
{
    arreglo[i] = 0;
}
Verificar Límites

La 0x300Ch: Verificá siempre los límites de los arreglos antes de acceder a sus elementos exige verificar siempre los límites de los arreglos antes de acceder a sus elementos.

1
2
3
4
5
6
7
void establecer_elemento(int *arreglo, size_t tamano, size_t indice, int valor)
{
    if (indice < tamano)
    {
        arreglo[indice] = valor;
    }
}

Conceptos Clave

Este apunte explora la gestión de memoria dinámica, el mecanismo que permite a los programas solicitar y liberar memoria durante la ejecución, habilitando estructuras de datos flexibles y adaptables.

Conclusiones

La gestión de memoria dinámica es una de las características más poderosas y peligrosas de C. Su dominio requiere comprender no solo las funciones y sintaxis, sino también los principios fundamentales de cómo funciona la memoria en un programa.

Las buenas prácticas presentadas en este apunte no son sugerencias opcionales: son requisitos para escribir código C profesional y confiable. Cada regla existe porque previene errores reales que han causado innumerables problemas en sistemas de producción.

A medida que adquirás experiencia, estas prácticas se vuelven segunda naturaleza. Inicialmente pueden parecer restrictivas, pero con el tiempo reconocerás que son liberadoras: te permiten escribir código complejo con confianza, sabiendo que has evitado las trampas más comunes.

Referencias y Lecturas Complementarias

Para profundizar en la gestión de memoria, consultá:

Para las reglas de estilo, consultá el documento 0x0001h: La claridad y prolijidad son de máxima importancia donde se detallan todas las convenciones utilizadas en este curso.