23 KiB
Memoria dinámica: malloc y free
Pila y montón
Un proceso tiene dos sitios principales donde guardar datos mientras se ejecuta:
| Pila (stack) | Montón (heap) | |
|---|---|---|
| Qué va ahí | Variables locales de una función | Lo que pides con malloc |
| Cuándo se libera | Sola, al salir de la función | Cuando llamas a free |
| Tamaño | Fijo y pequeño (unos 8 MB en Linux) | Grande, crece bajo demanda |
| Coste | Casi gratis | Llamada a función. A veces, llamada al sistema |
El problema de la pila: al salir de la función, sus variables dejan de existir. Si devuelves la dirección de una variable local, el que llama recibe un puntero a memoria que ya no es suya (dangling pointer). Leerlo es comportamiento indefinido: puede parecer que funciona, dar basura o colgar el programa.
Dónde están físicamente
En la misma RAM. La pila no está en un chip especial ni el montón en otro. La diferencia es de organización, no física:
- Cada proceso ve un espacio de direcciones virtual propio (ver 03-process-memory.md). Dentro de ese espacio, el sistema operativo coloca cada zona en un rango de direcciones distinto.
- La MMU traduce cada página virtual (4 KB) a un marco físico cualquiera de la RAM. Páginas contiguas de la pila pueden estar en marcos dispersos, e incluso en disco (swap) si falta memoria.
Disposición típica en Linux x86-64, de direcciones bajas a altas:
0x0000... código del programa (.text) r-x
datos globales (.data, .bss) rw-
[heap] ↓ crece hacia arriba rw- (malloc, brk)
... librerías y mmap ... (malloc grandes)
[stack] ↑ crece hacia abajo rw- (8 MB máx., ulimit -s)
0x7fff...
Se ve en vivo: cat /proc/self/maps muestra el mapa del propio cat, con
las líneas [heap] y [stack]. Para un proceso en marcha:
cat /proc/<pid>/maps.
[heap] no es todo el montón. La línea [heap] del mapa es solo la
zona clásica que malloc hace crecer con la llamada al sistema brk. Los
bloques grandes (más de 128 KB en glibc) los pide malloc con mmap, y
aparecen como zonas sin nombre. Chromium, V8 y el runtime de Go usan sus
propios gestores sobre mmap y pueden no tener [heap] en absoluto.
Comprobado con el proceso principal de VS Code: tiene [stack] pero no
[heap].
Ejemplo: dónde vive cada parte del buffer
Medido con gdb en hello (Fedora, 2026-10-06), en el punto de parada de
main:
| Qué | Expresión en gdb | Dirección | Zona |
|---|---|---|---|
El papel: la variable local buffer de main |
p &buffer |
0x7fffffffd520 |
[stack] |
| La casa: el struct de 24 bytes | p buffer |
0x4052b0 |
[heap] |
| Los bytes: el bloque de 128 | p buffer->data |
0x4052d0 |
[heap] |
- Son tres sitios: el puntero está en la pila, y los dos bloques de
malloc/reallocestán en el montón, separados entre sí. - Los campos del struct sí van juntos y en orden:
&buffer->lenes0x4052b8, 8 bytes después dedata. - El struct y los datos están a 32 bytes y no a 24:
mallocredondea el tamaño y guarda delante de cada bloque una cabecera con información parafree. info proc mappingsen gdb (o/proc/<pid>/maps) dice a qué zona pertenece cada dirección.
En Go la variable b := &Buffer{} también puede vivir en la pila y el
struct en el montón, pero lo decide el compilador (escape analysis) y no lo
ves.
Memoria virtual frente a memoria real
/proc/<pid>/status muestra dos cifras:
VmSize: direcciones virtuales reservadas. Reservar direcciones no gasta RAM.VmRSS(Resident Set Size): páginas que están de verdad en RAM porque alguien ha escrito en ellas. Es lo que cuenta para el OOM killer y para el límite de memoria de un contenedor.
Ejemplo real con el proceso principal de VS Code:
VmSize: 1519018808 kB ~1,4 TB
VmRSS: 293276 kB ~286 MB
V8 reserva un rango enorme de direcciones (su sandbox) para que los objetos de JavaScript no puedan apuntar fuera de él. En x86-64 cada proceso tiene 128 TB de espacio virtual, así que 1,4 TB es un 1 %.
Por qué la pila es tan rápida si está en la misma RAM:
- Reservar en la pila es restar un número a un registro (
rsp, el stack pointer): una instrucción. Al salir de la función se suma de vuelta. - Esas páginas se usan constantemente, así que están en la caché de la CPU.
mallocen cambio tiene que buscar un hueco libre en sus listas, y a veces pedir más memoria al núcleo con una llamada al sistema (brkommap).
En Go: la pila de cada goroutine no es la pila del sistema operativo. El runtime la reserva en su propio montón: empieza pequeña (unos KB) y la copia a un bloque mayor cuando se queda corta. Por eso puedes tener millones de goroutines y en C no puedes tener millones de hilos.
Qué hacía Go por mí
En Go puedes escribir esto sin problema:
func newBuffer() *Buffer {
b := Buffer{}
return &b
}
El compilador hace escape analysis: ve que b sobrevive a la función y la
coloca en el montón en lugar de la pila. Luego el recolector de basura (GC) la
libera cuando nadie la referencia. Se ve con go build -gcflags=-m
(moved to heap: b).
En C nada de eso existe. Tú decides dónde va cada dato y tú lo liberas.
malloc y free
Se declaran en <stdlib.h>.
malloc(n)reservanbytes en el montón y devuelve un puntero al primero.- El contenido no está inicializado: es basura. En Go,
newymakeponen todo a cero; aquí no. - Si no hay memoria, devuelve
NULL. Hay que comprobarlo siempre. - Devuelve
void *, un puntero "a cualquier cosa". En C se convierte solo al tipo de puntero que lo recibe, sin cast.
- El contenido no está inicializado: es basura. En Go,
free(p)devuelve esa memoria.- Después,
psigue apuntando a la misma dirección, pero ya no es tuya. Usarla (use after free) es comportamiento indefinido. - Liberar dos veces lo mismo (double free) también lo es.
free(NULL)no hace nada. Es seguro.
- Después,
calloc(count, size)es comomalloc, pero pone a cero la memoria.
Forma idiomática de reservar un struct:
struct escpos_buffer *buf = malloc(sizeof *buf);
sizeof *buf es "el tamaño de aquello a lo que apunta buf". Se calcula al
compilar, no lee nada en ejecución. Así, si cambia el tipo de buf, el tamaño
cambia solo.
realloc: cambiar el tamaño de un bloque
realloc(p, n) cambia el tamaño del bloque p a n bytes y devuelve un
puntero al bloque redimensionado:
- Si hay sitio justo detrás, lo agranda en el mismo sitio y devuelve la misma dirección.
- Si no, reserva un bloque nuevo, copia el contenido, libera el viejo y devuelve la dirección nueva. Cualquier otro puntero al bloque viejo queda colgando.
realloc(NULL, n)equivale amalloc(n). Por eso un buffer vacío puede empezar condata = NULL.- Si falla, devuelve
NULLy el bloque viejo sigue intacto y sigue siendo tuyo.
Qué bloque se redimensiona. En un buffer hay dos bloques:
buffer ──▶ [ data | len | cap ] struct: 24 bytes fijos, no crece nunca
│
└──▶ [ A A A A ... ] bytes: este es el que crece
Se hace realloc de buffer->data, nunca de buffer. Hacer realloc del
struct puede moverlo a otra dirección y liberar el viejo, y entonces el
puntero buffer que tiene el que llama queda colgando.
El error clásico:
p = realloc(p, n); /* MAL: si falla, p pasa a NULL y el bloque viejo se pierde (fuga) */
Lo correcto es guardar el resultado en una variable temporal, comprobarlo y solo entonces asignarlo.
En Go es exactamente lo que hace append por dentro. Por eso hay que
escribir s = append(s, x): si el array subyacente se movió, el slice viejo
apunta al sitio antiguo.
Colgante no es lo mismo que perdido. Tras un realloc que mueve el bloque,
el puntero viejo no cambia: sigue con la dirección antigua, pero esa memoria ya
está liberada. Es un puntero colgante, y usarlo es comportamiento indefinido.
"Perdido" es lo contrario, una fuga: memoria reservada sin ningún puntero.
C (realloc) |
Go (append) |
|
|---|---|---|
| ¿Copia al bloque nuevo? | Sí | Sí |
| ¿Libera el bloque viejo? | Sí, en el acto | No; el GC lo mantiene vivo mientras algo lo referencie |
| Puntero/slice viejo | Colgante: comportamiento indefinido | Válido pero desconectado: ya no ve los cambios |
| Síntoma del fallo | Cuelgue o corrupción del montón | Datos incorrectos, sin ningún error |
a := make([]byte, 3) // len 3, cap 3: lleno
b := a // b comparte el array con a
a = append(a, 'X') // no cabe: nuevo array
a[0] = 'Z'
fmt.Println(b[0]) // 0: b sigue en el array viejo
Si a hubiera tenido capacidad libre, append no movería nada y b[0] valdría
'Z'. El resultado depende de cap.
Qué hace bytes.Buffer por debajo. Es el mismo algoritmo que
escpos_buffer_append_byte: 64 bytes iniciales (smallBufferSize) y duplicar
al llenarse. Diferencias: nunca crece en el sitio (siempre reserva y copia),
pone la memoria a cero (malloc no), redondea la capacidad al tamaño de clase
del asignador de Go, comprueba los índices y, si falla, hace panic en vez de
devolver un error. El struct bytes.Buffer puede vivir en la pila si no
escapa; solo los datos van al montón.
Estrategia de crecimiento
Si el buffer creciera de byte en byte, cada byte nuevo podría provocar una
copia de todo lo anterior: añadir n bytes costaría del orden de n². La
solución es duplicar la capacidad cada vez que se llena. Así hay pocas
copias, y el coste medio de añadir un byte es constante (coste amortizado
O(1)).
append en Go duplica hasta 256 elementos y a partir de ahí crece más despacio
(unos 1,25×).
memcpy: copiar bloques de bytes
memcpy(dst, src, n) (en <string.h>) copia n bytes desde la dirección
src a la dirección dst. Su firma:
void *memcpy(void *dst, const void *src, size_t n);
| Parte | Qué significa |
|---|---|
void *dst |
Dirección de destino. void * es "puntero a cualquier cosa": acepta un uint8_t *, un int * o un struct x * sin cast |
const void *src |
Dirección de origen. El const es la promesa: memcpy no modifica tus datos |
size_t n |
Cuántos bytes copiar. Se pasa aparte porque un puntero no sabe cuántos hay detrás |
devuelve void * |
El mismo dst. Casi nunca se usa |
Fíjate en que su firma resuelve el mismo problema que cualquier función que
recibe un bloque de datos: puntero, cantidad y const en lo que solo se lee.
n va en bytes, no en elementos. Con uint8_t coincide. Para copiar 10
int, n es 10 * sizeof(int), o sea, 40.
Comparado con el copy(dst, src) de Go:
- No sabe nada de tamaños: copia exactamente
nbytes, quepan o no. Si endstno haynbytes reservados, escribe fuera del bloque. - Los dos bloques no pueden solaparse. Si se solapan, se usa
memmove. - Go copia
min(len(dst), len(src))y devuelve cuántos copió: nunca se sale.memcpyno puede comprobar nada porque no conoce los tamaños.
restrict: la promesa de que no se solapan
Desde C99 la firma real lleva restrict:
void *memcpy(void *restrict dst, const void *restrict src, size_t n);
restrict en un puntero es una promesa del programador al compilador:
mientras dura la función, a la memoria que se toca por ese puntero no se accede
por ningún otro. Aquí quiere decir que dst y src no se solapan.
- Para qué sirve: sabiendo que no se solapan, el compilador puede copiar en
bloques de 16 o 32 bytes, reordenar y no volver a leer
srcdespués de cada escritura endst. Sin la promesa, tendría que suponer que escribir endstpuede cambiarsrc. - Quién lo comprueba: nadie. Si rompes la promesa (con bloques que se
solapan), es comportamiento indefinido. Para bloques solapados está
memmove, que no llevarestrict. - Para quien llama no cambia nada: se llama igual que en C89.
man 3 memcpy en Fedora lo escribe como void dest[restrict .n]. Es una
notación de la página de manual para decir que el bloque mide n. No es C
válido.
Versión del estándar: el Makefile compila con -std=c17, así que
restrict existe. Para comprobar qué estándar usa el compilador:
echo | gcc -std=c17 -dM -E - | grep __STDC_VERSION__ da 201710L (C17). Sin
-std, gcc 15 usa C23 (202311L).
En Go no existe. El compilador tiene que suponer lo peor cuando dos slices
pueden compartir memoria, y copy funciona aunque se solapen, como memmove.
Por qué usarlo en vez de un bucle: glibc implementa memcpy en ensamblador
optimizado, con instrucciones que copian 16 o 32 bytes de una vez (SIMD). A
veces gcc incluso convierte un bucle de copia en una llamada a memcpy. Y
además dice lo que haces en una sola línea.
La documentación de cualquier función de la librería de C se consulta con
man 3 memcpy. El 3 es la sección de funciones de librería; la 2 es la de
llamadas al sistema.
Para copiar al final del buffer hace falta la dirección de la casilla len:
&buffer->data[buffer->len] /* "la dirección de la casilla len" */
buffer->data + buffer->len /* lo mismo: aritmética de punteros */
Sumar un número a un puntero avanza ese número de elementos (no de bytes;
aquí coincide porque cada elemento es un uint8_t).
Desbordamiento al calcular tamaños
len + count o cap * 2 pueden pasar del máximo de size_t y dar la vuelta
a un número pequeño. Entonces realloc reserva poco y memcpy escribe fuera.
Con datos que vienen de fuera (por ejemplo, desde Go mediante cgo) hay que
comprobarlo antes de sumar: if (count > SIZE_MAX - len). SIZE_MAX está en
<stdint.h>.
Fugas de memoria
Si pierdes el último puntero a un bloque sin hacer free, ese bloque queda
reservado hasta que acabe el proceso: es una fuga (memory leak). En un
programa que dura poco no se nota. En un servicio que imprime tiques todo el
día, la memoria crece hasta que el sistema lo mata.
En Go el GC lo evita. Las fugas que hay en Go son de otro tipo: goroutines bloqueadas o referencias que guardas sin querer.
Qué pasa cuando se acaba la memoria
Linux: overcommit y OOM killer.
malloccasi nunca devuelveNULLen Linux. El núcleo solo reserva direcciones virtuales, y promete más memoria de la que tiene (overcommit). La RAM física se asigna página a página, la primera vez que escribes en ella, mediante un fallo de página.- Si llega un momento en que alguien escribe y no queda RAM ni swap, el núcleo
no puede cumplir la promesa. Entonces actúa el OOM killer (Out Of
Memory): elige el proceso con mayor
oom_score, que es básicamente el que más memoria usa, y le envíaSIGKILL. SIGKILLno se puede capturar ni ignorar. El proceso muere en el acto, sindefer, sin cerrar ficheros y sin dejar un error en su propio log.- Queda rastro en el log del núcleo:
journalctl -k | grep -i "out of memory"(Killed process 1234 (hello) ...). - Fedora tiene además
systemd-oomd, que actúa antes, cuando detecta presión de memoria sostenida, y mata grupos de procesos enteros. - Contenedores: un límite de memoria de Docker o Kubernetes es un cgroup.
Al superarlo actúa el mismo OOM killer, pero solo dentro del contenedor. Es
el
OOMKilledde Kubernetes (código de salida 137 = 128 + 9, la señalSIGKILL).
Windows no tiene OOM killer. Lleva la cuenta de la memoria prometida
(commit charge) frente a RAM + fichero de paginación. Si se supera, malloc
sí devuelve NULL.
Android tiene lmkd (low memory killer), que mata primero las apps en
segundo plano.
Por eso hay que comprobar NULL aunque en Linux casi nunca salga: en Windows
sí ocurre, también con límites como ulimit -v, y con peticiones absurdas
(por ejemplo, un tamaño calculado mal que da varios exabytes).
En Go pasa lo mismo. Si el runtime no consigue memoria, el programa muere
con fatal error: runtime: out of memory, que no se puede recuperar con
recover. Y si lo mata el OOM killer, ni siquiera eso. GOMEMLIMIT le dice al
GC que trabaje más cuando se acerca a un límite, para no llegar ahí.
Propiedad (ownership)
Como no hay GC, cada puntero tiene un dueño: el código responsable de liberarlo. La API tiene que dejarlo claro. La convención del proyecto es documentar en la cabecera, en cada función, quién reserva y quién libera.
El patrón típico es una pareja de funciones:
x_new()reserva y devuelve el puntero. El que llama pasa a ser el dueño.x_free(p)libera. Después de llamarla, el que llama no debe usarp.
Es parecido a defer f.Close() en Go: un recurso que tienes que devolver tú.
La diferencia es que en C también la memoria es un recurso que se devuelve a
mano.
El dueño es quien tiene la obligación de liberar. No es quien reservó la
memoria, sino quien debe acabar llamando a free. Esa obligación puede pasar
de una función a otra, y por eso hay que escribirla en la cabecera: el tipo
escpos_buffer * no dice nada sobre quién libera.
Dos funciones de la API que devuelven punteros con dueños distintos:
| Función | Quién reserva | Dueño | Qué debe hacer el que llama |
|---|---|---|---|
escpos_buffer_new() |
la librería, con malloc |
el que llama (se le traspasa) | llamar a escpos_buffer_free una vez, y no usar el puntero después |
escpos_version() |
nadie: es un literal de cadena en memoria de solo lectura | la librería (vive mientras vive el programa) | solo leerlo. Si hace free, el comportamiento es indefinido (normalmente el programa aborta) |
Mirando solo las firmas, ambas "devuelven un puntero". El comentario es lo que dice qué hacer con cada una.
Con cgo (fase 12) importa todavía más: el GC de Go no ve la memoria de C.
El código Go que llame a escpos_buffer_new tendrá que hacer
defer C.escpos_buffer_free(b), igual que con un fichero.
Liberar en los caminos de error: C no tiene defer
En Go, defer b.Close() se ejecuta salga la función por donde salga. En C no
existe nada así: cada return tiene que dejar liberado todo lo que se creó
antes. Con varios recursos, repetir los free delante de cada return es
largo y es fácil olvidarse de alguno.
El patrón clásico de C es un único punto de limpieza al final, al que se
salta con goto:
int run(void)
{
int result = 1; /* se supone fallo hasta llegar al final */
thing *a = NULL; /* todo a NULL desde el principio */
thing *b = NULL;
a = thing_new();
if (a == NULL) { goto cleanup; }
b = thing_new();
if (b == NULL) { goto cleanup; }
/* ... trabajo ... */
result = 0; /* todo ha ido bien */
cleanup: /* etiqueta: destino del goto */
thing_free(b); /* seguro aunque sea NULL */
thing_free(a);
return result;
}
Variables locales sin inicializar = basura. En Go toda variable nace con
su valor cero (nil, 0, ""). En C, una variable local que no inicializas
contiene lo que hubiera antes en esa posición de la pila. Si un goto salta
por encima de la línea escpos_buffer *b = escpos_buffer_new();, esa
asignación no se ejecuta y b vale cualquier cosa. Pasarla a free es
comportamiento indefinido. (Las variables globales y static sí empiezan a
cero.)
Por qué funciona:
- Todos los punteros empiezan en
NULL, y la función de liberar aceptaNULLsin hacer nada. Por esoescpos_buffer_freese diseñó así. Da igual en qué punto falle: la limpieza libera lo que exista e ignora lo demás. - Hay un solo
return. Al añadir un recurso nuevo, se añade sufreeen un solo sitio. - Se libera en orden inverso al de creación, como hace
defer(LIFO).
goto tiene mala fama por el código "espagueti", pero este uso, saltar
hacia delante a la limpieza, es idiomático. El núcleo de Linux lo usa por
todas partes.
gcc y clang tienen una extensión, __attribute__((cleanup(f))), que se
parece a defer. No es C estándar y no funciona en MSVC, así que en este
proyecto no se usa.
Valgrind
valgrind ejecuta tu programa en una CPU simulada y vigila cada acceso a
memoria. Detecta fugas, lecturas de memoria sin inicializar, use after free
y accesos fuera de un bloque. El programa va unas 20-50 veces más lento.
valgrind --leak-check=full ./build/hello
Al final del informe:
All heap blocks were freed -- no leaks are possible: todo bien.definitely lost: N bytes in M blocks: fuga. Con-g, Valgrind indica la línea delmallocque nunca se liberó.
Solo funciona en Linux. Por eso la parte portable se prueba en Fedora.
Cómo leer un informe de fuga
Informe real, quitando el escpos_buffer_free de hello.c:
HEAP SUMMARY:
in use at exit: 24 bytes in 1 blocks
total heap usage: 2 allocs, 1 frees, 4,120 bytes allocated
24 bytes in 1 blocks are definitely lost in loss record 1 of 1
at 0x483FB26: malloc (vg_replace_malloc.c:447)
by 0x4004DD: escpos_buffer_new (buffer.c:16)
by 0x4004A7: main (hello.c:10)
2 allocs: una es nuestra (24 bytes: el struct). La otra (4.096 bytes) es el búfer interno que creaprintfpara la salida estándar, que la librería de C libera al terminar. Valgrind vigila todo el proceso, no solo tu código.- La pila de llamadas se lee de abajo arriba:
main(línea 10 dehello.c) llamó aescpos_buffer_new, que en la línea 16 debuffer.cllamó amalloc. Los ficheros y las líneas salen gracias a-g. - Valgrind dice dónde se reservó el bloque perdido, no dónde faltó el
free. Eso lo deduces tú: ¿quién era el dueño de ese puntero?
Cómo sabe Valgrind que es una fuga. Al terminar el programa, recorre toda
la memoria que todavía es accesible (pila, variables globales, registros y los
demás bloques del montón) buscando algún valor que sea la dirección del bloque.
Si no encuentra ninguno, ningún código podría ya hacer free de ese bloque: la
fuga es segura, no una sospecha. Es la fase de marcado del GC de Go: el GC
libera lo que no alcanza, y Valgrind lo denuncia.
En el ejemplo, el único puntero era la variable local buffer de main, que
estaba en la pila. Cuando main terminó, esa variable desapareció y con ella
la última referencia al bloque.
Tipos de pérdida en LEAK SUMMARY:
| Tipo | Significado |
|---|---|
definitely lost |
Ningún puntero apunta ya al bloque. Fuga segura |
indirectly lost |
Solo se llega al bloque desde otro bloque perdido. Por ejemplo, el data de un buffer cuyo struct se perdió |
possibly lost |
Solo hay punteros al interior del bloque, no a su inicio |
still reachable |
Al salir aún había un puntero (por ejemplo, una variable global). No es una fuga, pero nadie lo liberó |