Lección 6 de 8: estructuras y memoria dinámica en C. Con struct se agrupan datos para representar cosas del mundo real, y con malloc y free se pide y se devuelve memoria mientras el programa corre. Es la parte de C que más poder da y la que más errores causa, así que aquí aprenderás también a usar Valgrind y AddressSanitizer para encontrar fugas, usos tras liberar y dobles liberaciones.
Estructuras: struct
Una estructura agrupa varios datos de tipos distintos bajo un nombre. Es lo más parecido que tiene C a una clase, sin métodos:
#include <stdio.h>
struct producto {
char nombre[32];
int existencias;
double precio;
};
int main(void)
{
struct producto teclado = {"Teclado mecánico", 12, 1299.00};
struct producto raton = {.precio = 349.50, .nombre = "Ratón", .existencias = 40};
teclado.existencias -= 2;
printf("%s: %d a %.2f\n", teclado.nombre, teclado.existencias, teclado.precio);
printf("%s: %d a %.2f\n", raton.nombre, raton.existencias, raton.precio);
struct producto copia = teclado;
copia.existencias = 0;
printf("Original %d, copia %d\n", teclado.existencias, copia.existencias);
return 0;
}salidaTeclado mecánico: 10 a 1299.00 Ratón: 40 a 349.50 Original 10, copia 0
A los campos se llega con un punto. La segunda forma de inicializar, con .campo = valor, se llama inicializadores designados: no depende del orden y los campos que no nombres quedan a cero. Asignar una estructura a otra copia todos sus campos, array incluido.
typedef, punteros a struct y la flecha
typedef da otro nombre a un tipo, y se usa mucho para no repetir struct. Y como pasar una estructura grande a una función la copia entera, lo normal es pasar un puntero. Para llegar a un campo a través de un puntero se usa la flecha: p->campo, que es lo mismo que (*p).campo:
#include <stdio.h>
typedef struct {
double x;
double y;
} Punto;
void mover(Punto *p, double dx, double dy)
{
p->x += dx;
p->y += dy;
}
double area(const Punto *a, const Punto *b)
{
double ancho = b->x - a->x;
double alto = b->y - a->y;
return ancho * alto;
}
int main(void)
{
Punto esquina = {0, 0};
Punto opuesta = {4, 3};
printf("Área: %.1f\n", area(&esquina, &opuesta));
mover(&opuesta, 1, 1);
printf("Ahora (%.1f, %.1f), área %.1f\n", opuesta.x, opuesta.y, area(&esquina, &opuesta));
return 0;
}salidaÁrea: 12.0 Ahora (5.0, 4.0), área 20.0
enum
#include <stdio.h>
typedef enum { PENDIENTE, ENVIADO, ENTREGADO, CANCELADO } Estado;
const char *nombre_estado(Estado e)
{
switch (e) {
case PENDIENTE: return "pendiente";
case ENVIADO: return "enviado";
case ENTREGADO: return "entregado";
case CANCELADO: return "cancelado";
}
return "desconocido";
}
int main(void)
{
Estado pedido = ENVIADO;
printf("El pedido está %s (%d)\n", nombre_estado(pedido), pedido);
pedido = ENTREGADO;
printf("El pedido está %s (%d)\n", nombre_estado(pedido), pedido);
return 0;
}salidaEl pedido está enviado (1) El pedido está entregado (2)
Un enum da nombre a una serie de constantes enteras, que empiezan en 0. Con -Wall, si un switch sobre un enum se deja algún valor sin case, gcc avisa: al añadir un estado nuevo, el compilador te dice todos los sitios que hay que actualizar.
La pila y el montón
Hasta ahora todas las variables vivían en la pila: aparecen al entrar en la función y desaparecen al salir. Pero muchas veces no sabes cuánta memoria necesitas hasta que el programa corre (cuántas líneas tiene un archivo, cuántos clientes hay), o necesitas que algo sobreviva a la función que lo crea. Para eso está el montón (heap): memoria que pides y devuelves tú.
malloc y free. Esquema: decodigo.com.
malloc y free
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
size_t n = 5;
int *cuadrados = malloc(n * sizeof *cuadrados);
if (cuadrados == nullptr) {
fprintf(stderr, "Sin memoria\n");
return EXIT_FAILURE;
}
for (size_t i = 0; i < n; i++) {
cuadrados[i] = (int) (i * i);
}
for (size_t i = 0; i < n; i++) {
printf("%d ", cuadrados[i]);
}
printf("\n");
free(cuadrados);
cuadrados = nullptr;
return 0;
}salida0 1 4 9 16
Cuatro costumbres en un solo ejemplo, y conviene adoptarlas todas:
malloc(n * sizeof *cuadrados): el tamaño se calcula con el tipo de lo apuntado, no escribiendosizeof(int)a mano. Si un día cambias el tipo, la cuenta sigue bien.- Comprobar si devolvió
nullptr:mallocpuede fallar. - Un
freepor cadamalloc, cuando ya no la necesites. - Poner el puntero a
nullptrtras liberar, para que un uso posterior falle de forma clara en vez de tocar memoria ajena.
calloc(n, tamaño) hace lo mismo que malloc pero pone la memoria a cero y comprueba que n * tamaño no se desborde. realloc cambia el tamaño de un bloque ya reservado, conservando su contenido.
Un array que crece
Con realloc se construye la estructura de datos más útil que no trae C: un array que crece según hace falta, como una lista de Python o un ArrayList de Java. Se duplica la capacidad cada vez que se llena, para que añadir sea rápido de media:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *datos;
size_t largo;
size_t capacidad;
} Vector;
bool vector_agregar(Vector *v, int valor)
{
if (v->largo == v->capacidad) {
size_t nueva = v->capacidad ? v->capacidad * 2 : 4;
int *mas = realloc(v->datos, nueva * sizeof *mas);
if (mas == nullptr) return false;
v->datos = mas;
v->capacidad = nueva;
}
v->datos[v->largo++] = valor;
return true;
}
void vector_liberar(Vector *v)
{
free(v->datos);
*v = (Vector) {};
}
int main(void)
{
Vector v = {};
for (int i = 1; i <= 10; i++) {
if (!vector_agregar(&v, i * 10)) return EXIT_FAILURE;
if (v.largo == v.capacidad) {
printf("lleno con %zu\n", v.largo);
}
}
printf("largo %zu, capacidad %zu, último %d\n", v.largo, v.capacidad, v.datos[v.largo - 1]);
vector_liberar(&v);
return 0;
}salidalleno con 4 lleno con 8 largo 10, capacidad 16, último 100
Un detalle importante: el resultado de realloc se guarda primero en otra variable (mas). Si se escribiera directamente v->datos = realloc(v->datos, ...) y fallara, devolvería nullptr, perderías el único puntero al bloque original y tendrías una fuga.
Los tres errores de la memoria dinámica
Gestionar la memoria a mano trae tres errores clásicos. El compilador solo atrapa los casos más evidentes, cuando todo ocurre en la misma función, y por eso existen dos herramientas que todo programador de C debe conocer: Valgrind, que ejecuta tu programa en una máquina virtual que vigila cada acceso (sin recompilar, pero unas 20 o 50 veces más lento), y AddressSanitizer, que lo compila con comprobaciones (-fsanitize=address, unas 2 veces más lento).
1. La fuga de memoria: olvidar el free
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *copiar_mayusculas(const char *s)
{
char *copia = malloc(strlen(s) + 1);
if (copia == nullptr) return nullptr;
for (size_t i = 0; s[i] != '\0'; i++) {
copia[i] = (s[i] >= 'a' && s[i] <= 'z') ? s[i] - 32 : s[i];
}
copia[strlen(s)] = '\0';
return copia;
}
int main(void)
{
for (int i = 0; i < 3; i++) {
char *grito = copiar_mayusculas("hola");
printf("%s\n", grito);
}
return 0;
}gcc -Wall -Wextra -g fuga.c -o fuga valgrind --leak-check=full ./fuga
salida con errorHOLA HOLA HOLA … ==51== 15 bytes in 3 blocks are definitely lost in loss record 1 of 1 ==51== at 0x4844818: malloc (vg_replace_malloc.c:446) ==51== by 0x401169: copiar_mayusculas (fuga.c:7) ==51== by 0x401228: main (fuga.c:19) … ==51== LEAK SUMMARY: ==51== definitely lost: 15 bytes in 3 blocks … ==51== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
Valgrind dice cuánta memoria quedó sin liberar (3 bloques de 5 bytes, uno por vuelta), dónde se reservó (fuga.c:7, el malloc) y quién llamó a la función (fuga.c:19). Una fuga en un programa que termina enseguida no hace daño, pero en un servidor que funciona durante meses va comiéndose la memoria hasta que el sistema lo mata. La solución: free(grito); después del printf. Fíjate también en el strlen(s) + 1: el + 1 es el hueco del '\0', y olvidarlo es otro clásico.
2. Usar la memoria después de liberarla
#include <stdio.h>
#include <stdlib.h>
typedef struct {
char nombre[16];
int saldo;
} Cuenta;
int main(void)
{
Cuenta *c = malloc(sizeof *c);
if (c == nullptr) return EXIT_FAILURE;
c->saldo = 500;
free(c);
printf("Saldo: %d\n", c->saldo);
return 0;
}Este caso es tan evidente que GCC ya lo avisa al compilar (-Wuse-after-free, incluido en -Wall):
gcc: avisouso_tras_free.c: In function 'main':
uso_tras_free.c:16:5: warning: pointer 'c' used after 'free' [-Wuse-after-free]
16 | printf("Saldo: %d\n", c->saldo);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
uso_tras_free.c:15:5: note: call to 'free' here
15 | free(c);
| ^~~~~~~Pero basta con que el free esté en otra función para que el compilador ya no lo vea. ASan lo atrapa siempre que ocurra al ejecutar:
salida con error==167==ERROR: AddressSanitizer: heap-use-after-free on address 0x77c098be0050 at pc 0x00000040125a bp 0x7ffcc622f470 sp 0x7ffcc622f468
READ of size 4 at 0x77c098be0050 thread T0
#0 0x000000401259 in main /curso/uso_tras_free.c:16
…
0x77c098be0050 is located 16 bytes inside of 20-byte region [0x77c098be0040,0x77c098be0054)
freed by thread T0 here:
…
#1 0x00000040121e in main /curso/uso_tras_free.c:15
…
previously allocated by thread T0 here:
#0 0x7b9099fbc08b in malloc (/usr/local/lib64/libasan.so.8+0x11a08b)
#1 0x0000004011b7 in main /curso/uso_tras_free.c:11
…
SUMMARY: AddressSanitizer: heap-use-after-free /curso/uso_tras_free.c:16 in mainASan dice que es un heap-use-after-free, la línea del acceso (16), dónde se liberó (15) y dónde se había reservado (11). Sin ASan, este programa casi siempre imprime «Saldo: 500», porque la memoria liberada todavía guarda el dato… hasta que otra reserva la reutiliza.
3. Liberar dos veces
#include <stdlib.h>
int main(void)
{
char *buffer = malloc(64);
free(buffer);
free(buffer);
return 0;
}salida con error==18==ERROR: AddressSanitizer: attempting double-free on 0x7655433e0080 in thread T0:
…
#1 0x000000401183 in main /curso/doble_free.c:7
…
0x7655433e0080 is located 0 bytes inside of 64-byte region [0x7655433e0080,0x7655433e00c0)
freed by thread T0 here:
…
#1 0x000000401177 in main /curso/doble_free.c:6
…
previously allocated by thread T0 here:
#0 0x79f5447cc08b in malloc (/usr/local/lib64/libasan.so.8+0x11a08b)
#1 0x000000401167 in main /curso/doble_free.c:5
…
SUMMARY: AddressSanitizer: double-free /curso/doble_free.c:7 in mainLiberar dos veces corrompe las estructuras internas de malloc. GCC vuelve a avisar al compilar en este caso tan sencillo, y ASan lo atrapa al ejecutar; sin él, glibc a veces lo detecta y aborta con free(): double free detected, y a veces no. Poner el puntero a nullptr tras el primer free lo evita, porque free(nullptr) no hace nada.
Ojo con la seguridad: el uso tras liberar es explotable
De los tres errores, el uso tras liberar es el más grave. Si un atacante consigue que el programa reserve un bloque nuevo justo donde estaba el liberado, y rellenarlo con datos suyos, el puntero viejo pasa a leer o escribir esos datos: un campo que era un saldo o un puntero a función ahora vale lo que el atacante quiso. Es una de las categorías de fallos más explotadas en navegadores y sistemas operativos, y la razón principal por la que Google y Microsoft cuentan que cerca del 70 % de sus fallos de seguridad graves son de memoria. Las defensas: un dueño claro para cada bloque, poner a nullptr al liberar, y probar siempre con -fsanitize=address. Más en Seguridad en C.
Resumen
- ParaEscribeRecuerda
- Agrupar datos
typedef struct { ... } Nombre;inicializa con.campo = valor - Campo a través de un puntero
p->campopasa estructuras grandes por puntero - Pedir memoria
T *p = malloc(n * sizeof *p);compruebanullptr - Pedirla a cero
calloc(n, sizeof *p)comprueba el desbordamiento de n × tamaño - Cambiar el tamaño
tmp = realloc(p, ...)nunca sobre el mismo puntero - Devolverla
free(p); p = nullptr;una vez por cada reserva - Buscar fugas
valgrind --leak-check=fullo ASan, que también las busca
Retos
Reto 1. Define un struct para un alumno (nombre y tres notas) y una función que devuelva su media. Prueba con dos alumnos.
Ver la solución
#include <stdio.h>
typedef struct {
const char *nombre;
double notas[3];
} Alumno;
double media(const Alumno *a)
{
double suma = 0;
for (int i = 0; i < 3; i++) suma += a->notas[i];
return suma / 3;
}
int main(void)
{
Alumno clase[] = {
{"Lucía", {9.5, 8.0, 10.0}},
{"Diego", {6.0, 7.5, 5.0}},
};
for (int i = 0; i < 2; i++) {
printf("%s: %.2f\n", clase[i].nombre, media(&clase[i]));
}
return 0;
}salidaLucía: 9.17 Diego: 6.17
Un array de estructuras se inicializa con llaves anidadas. La función recibe un puntero a const: no copia el alumno y promete no cambiarlo.
Reto 2. Escribe char *repetir(const char *s, int veces), que devuelva una cadena nueva con s repetida veces veces. Quien la llama se encarga del free.
Ver la solución
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *repetir(const char *s, int veces)
{
if (veces < 0) return nullptr;
size_t largo = strlen(s);
char *r = malloc(largo * (size_t) veces + 1);
if (r == nullptr) return nullptr;
for (int i = 0; i < veces; i++) {
memcpy(r + largo * (size_t) i, s, largo);
}
r[largo * (size_t) veces] = '\0';
return r;
}
int main(void)
{
char *eco = repetir("eco ", 3);
if (eco == nullptr) return EXIT_FAILURE;
printf("[%s]\n", eco);
free(eco);
return 0;
}salida[eco eco eco ]
Compilado con ASan, que también busca fugas al terminar: no protesta. Para datos que vengan de fuera faltaría comprobar que largo * veces no se desborde, con ckd_mul de la lección 2.
Reto 3. ¿Qué escribe? struct a { char c; int n; }; struct b { char c; char d; int n; }; y printf("%zu %zu\n", sizeof(struct a), sizeof(struct b));
Ver la solución
#include <stdio.h>
struct a { char c; int n; };
struct b { char c; char d; int n; };
int main(void)
{
printf("%zu %zu\n", sizeof(struct a), sizeof(struct b));
return 0;
}salida8 8
Ninguno ocupa 5 o 6. El procesador lee más rápido un int que empieza en una dirección múltiplo de 4, así que el compilador añade bytes de relleno (padding) tras los char. En struct b, la d cabe en el hueco que de todas formas iba a quedar. Ordenar los campos de mayor a menor tamaño ahorra memoria.
Todo el código de esta lección se compiló con GCC 15.3 (C23, -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con gcc --version: hace falta GCC 15 o Clang 19 para todo lo de C23.
