malloc, free, append

This commit is contained in:
2026-10-02 11:36:59 +02:00
parent b8141311aa
commit 9d517f3607
13 changed files with 960 additions and 13 deletions
+329
View File
@@ -0,0 +1,329 @@
# 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](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]`.
### 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.
- `malloc` en 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 (`brk` o `mmap`).
**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:
```go
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)` reserva `n` bytes en el montón y devuelve un puntero al primero.
- El contenido **no está inicializado**: es basura. En Go, `new` y `make`
ponen 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*.
- `free(p)` devuelve esa memoria.
- Después, `p` sigue 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.
- `calloc(count, size)` es como `malloc`, pero pone a cero la memoria.
Forma idiomática de reservar un `struct`:
```c
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 a `malloc(n)`. Por eso un buffer vacío puede
empezar con `data = NULL`.
- Si falla, devuelve `NULL` y **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:
```c
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.
### 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×).
## 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.**
- `malloc` casi nunca devuelve `NULL` en 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ía `SIGKILL`.
- `SIGKILL` no se puede capturar ni ignorar. El proceso muere en el acto, sin
`defer`, 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 `OOMKilled` de Kubernetes (código de salida 137 = 128 + 9, la señal
`SIGKILL`).
**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 usar `p`.
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.
## 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 del `malloc` que 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 crea `printf` para 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 de
`hello.c`) llamó a `escpos_buffer_new`, que en la línea 16 de `buffer.c`
llamó a `malloc`. 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ó |