Files
printer-driver/docs/07-dynamic-memory.md
T
2026-10-02 11:36:59 +02:00

330 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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ó |