Este post es mi traducción del post A Quick Tutorial on Implementing and Debugging Malloc, Free, Calloc, and Realloc de Dan Luu. Cualquier error o sugerencia sobre la traducción es bienvenida, y si querés que haga de intermediario para sugerirle cambios de contenido a él, también vale.
¡Implementemos nuestro propio malloc
y veamos cómo funciona con programas pre-existentes!
Este tutorial asume que sabés qué es un puntero, y que *ptr
dereferencia un puntero, y que ptr->foo
significa (*ptr).foo
, que malloc
se usa para reservar (allocar) espacio dinámicamente, y que te es familiar el concepto de lista enlazada. Si decidís seguir este tutorial sin tener un claro conocimiento de C, contame qué partes convendría explicar un poco más. Si querés mirar todo el código de una, está disponible acá.
Presentaciones aparte, la firma de malloc
es:
1
|
|
Recibe un número de bytes y devuelve un puntero a un bloque de memoria de ese tamaño.
Hay varias formas de implementar esto. Nosotros vamos a elegir arbitrariamente utilizar la llamada al sistema sbrk
. El sistema operativo reserva espacios de stack (pila) y heap (montículo) para los procesos, y sbrk
nos permite manipular el heap. sbrk(0)
devuelve un puntero al tope del heap. sbrk(foo)
incrementa el tamaño del heap en foo
bytes y devuelve un puntero al tope previo.
Si queremos implementar un malloc
realmente simple, podemos hacer algo como:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Cuando un programa le pide espacio a malloc
, éste se lo pide a sbrk
para incrementar el tamaño del heap, y devuelve un puntero al inicio de la nueva región en el heap. Esta implementación falla en un tecnisismo, dado que malloc(0)
debería devolver NULL
u otro puntero que se le pueda pasar a free
sin romper todo, pero básicamente funciona.
Pero, hablando de free
… ¿Cómo funciona? Su prototipo es
1
|
|
Cuando le pasan a free
un puntero que había sido devuelto por malloc
debe liberar ese espacio. Pero si nos dan un puntero a algo reservado por nuestro malloc
, no tenemos idea de cuál es el tamaño de ese bloque. ¿Dónde almacenamos eso? Si tuviéramos un malloc
funcionando, podríamos malloc
ear algo de espacio y guardarlo ahí, pero vamos a meternos en muchos problemas si necesitamos llamar a malloc
para reservar espacio cada vez que llamamos a malloc
para reservar espacio :)
Un truco común para evitar este problema es guardar meta-información sobre la región de memoria en el espacio previo al puntero que devolvemos. Supongamos que el tope del heap actual está en 0x1000
y pedimos 0x400
bytes. Nuestro malloc
actual va a pedir 0x400
bytes a sbrk
y devolver un puntero a 0x1000
. Si, en cambio, guardáramos, digamos, 0x10
bytes para almacenar información sobre el propio bloque, nuestro malloc
pediría 0x410
bytes a sbrk
y devolvería un puntero a 0x1010
, escondiendo nuestro bloque de 0x10
bytes de meta-información del código que está llamando a malloc
.
Eso nos permite liberar un bloque, pero… ¿y ahora? Esa región de heap que nos da el SO tiene que ser contigua, entonces no podemos devolverle al SO un pedacito de memoria que esté en el medio. Incluso si quisiéramos copiar todos los datos que están después de la región liberada hacia adelante para rellenar el hueco, cosa de poder liberar el pedazo final del heap, no tenemos forma de actualizar todos los punteros que haya hacia esa zona de memoria a lo largo de todo nuestro programa.
En cambio, podemos marcar que esos bloques fueron liberados sin devolvérselos al SO, y en invocaciones futuras de malloc
podríamos reusar esos bloques. Pero para hacer eso necesitamos poder acceder a la meta-información de cada bloque. Hay varias formas de hacerlo, nosotros vamos a elegir arbitrariamente hacerlo con una lista simplemente enlazada por cuestión de simplicidad.
Entonces, para cada bloque, queremos tener algo como:
1 2 3 4 5 6 7 8 |
|
Necesitamos saber el tamaño del bloque, si está o no libre, y cuál es el bloque siguiente. Hay también un número mágico ahí (el campo magic
) para ayudarnos a debuggear, pero no es realmente necesario. Le vamos a asignar valores arbitrarios que nos van a permitir saber qué parte del código fue la última en modificar esa estructura.
También necesitamos un inicio para nuestra lista enlazada:
1
|
|
Para nuestro malloc
, queremos reusar el espacio libre si fuera posible, _alloc_ando espacio sólo cuando no podemos reusar el que ya tenemos. Dado que tenemos esta estructura de lista enlazada, buscar un bloque libre y devolverlo es casi trivial. Cuando tenemos un pedido de algún tamaño, iteramos nuestra lista buscando algún bloque libre con tamaño suficiente.
1 2 3 4 5 6 7 8 |
|
Si no encontramos un bloque que nos sirva, le tenemos que pedir ese espacio al SO usando sbrk
y lo agregamos al final de nuestra lista.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Como en nuestra implementación original, pedimos espacio usando sbrk
, pero ahora le pedimos un poquito más de espacio para poder almacenar nuestra estructura, y además inicializamos los campos correctamente.
Ahora que tenemos las funciones auxiliares para ver si tenemos espacio libre y para pedirlo, nuestro malloc
es simple. Si nuestro puntero base global es NULL
, tenemos que pedir espacio y apuntar nuestro puntero base a ese nuevo bloque. Si no es NULL
, buscamos si podemos reusar el espacio existente. Si podemos, lo hacemos, y si no pedimos espacio nuevo y lo usamos.
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 |
|
Para quienes no se lleven mucho con C, devolvemos block+1
porque queremos devolver un puntero a la región siguiente a block_meta
. Dado que block
es un puntero del tipo struct block_meta
, hacerle +1
incrementa la dirección en sizeof(struct block_meta)
bytes.
Si sólo quisiéramos un malloc
sin un free
, podríamos haber usado nuestro malloc
original, mucho más simple. Entonces, ¡escribamos nuestro free
! Lo principal que free
tiene que hacer es setear el campo ->free
.
Como vamos a necesitar obtener la dirección de nuestro struct en varios lugares de nuestro código, definamos una función para ello:
1 2 3 |
|
Ahora que lo tenemos, este es free
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Además de asignar ->free
, es válido llamar a free
con un puntero NULL
, y por eso chequeamos por NULL
. Como free
no debería llamarse con cualquier dirección arbitraria o bloques que ya hayan sido liberados, assert
eamos que esas cosas no pasen.
En realidad no es necesario assert
ear nada, pero suele ayudar a debuggear. De hecho, cuando escribí este código tuve un bug que hubiera resultado en corromper silenciosamente los datos si los asserts no hubieran estado ahí. En cambio, el código falló por los assert
s, y debuggear el problema se volvió trivial.
Ahora que tenemos malloc
y free
, ¡podemos escribir programas usando nuestro propio gestor de memoria! Pero antes de meter nuestro gestor en programas ya existentes, necesitamos implementar un par de funciones comunes más: realloc
y calloc
. calloc
es simplemente un malloc
que inicializa la memoria en 0, así que arranquemos por realloc
. realloc
debería ajustar el tamaño de un bloque de memoria que obtuvimos a través de malloc
, calloc
o realloc
.
El prototipo de realloc
es:
1
|
|
Si le pasamos un puntero a NULL
, debería funcionar exactamente igual que malloc
. Si le pasamos un puntero previamente malloc
eado, debería liberar espacio si el tamaño es menor al previo, y asignar más espacio y copiar los datos existentes si el tamaño es mayor que el anterior.
Los programas pueden seguir funcionando si no hiciéramos lo de achicar el bloque o liberarlo, pero sí necesitamos reservar más espacio cuando el tamaño se incrementa, así que empecemos con eso:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Y ahora calloc
, que simplemente inicializa la memoria antes de devolver el puntero:
1 2 3 4 5 6 |
|
Notemos que no chequea overflows en nelem * elsize
, requerido por la especificación. Todo este código es simplemente lo mínimo necesario para que las cosas más o menos funcionen.
Y ahora que tenemos algo que más o menos funciona, usémoslo con los programas ya existentes (¡sin siquiera recompilarlos!).
Primero que nada, necesitamos compilar nuestro código. En Linux, algo como:
1
|
|
debería funcionar.
-g
agrega los símbolos de debug, para poder mirar nuestro código con gdb
o lldb
. -O0
va a ayudar a debuggear, evitando que en las optimizaciones se eliminen variables. -W -Wall -Wextra
agrega warnings adicionales. -shared -fPIC
nos permite linkear nuestro código dinámicamente, que es lo que nos permite usar nuestro código con binarios ya existentes.
En Mac usaríamos algo así:
1
|
|
Notar que sbrk
fue deprecado en las versiones recientes de OS X. Apple usa una definición poco ortodoxa de deprecado – algunas llamadas al sistema deprecadas directamente fallan. No probé esto en una Mac, así que puede que la implementación tenga fallas, o directamente no funcione en Mac.
Ahora, para que un binario use nuestro malloc
en Linux, tenemos que setear la variable de entorno LD_PRELOAD
. Si estás usando bash
, podés hacerlo así:
1
|
|
Si estás en Mac, el comando es:
1
|
|
Si todo funciona, podés correr cualquier binario arbitrario y debería funcionar normalmente (excepto que será un poquito más lento).
1 2 |
|
Si hubiera un bug, vas a ver algo así:
1 2 |
|
Debuggeando
¡Hablemos de debugear! Si te es familiar el uso del debugger para poner breakpoints, inspeccionar la memoria y ejecutar paso a paso el código, podés saltear esta sección e ir directamente a los ejercicios.
Esta sección asume que podés instalar gdb
en tu sistema. Si estás en Mac, probablemente quieras usar lldb
y traducir los comandos apropiadamente. Como no tengo idea de qué bugs podés encontrarte, voy a introducir algunos bugs en el código para mostrarte cómo encontrarlos y resolverlos.
Primero, necesito poder correr gdb
sin que genere un segmentation fault. Si ls
segfaultea y tratamos de correr gdb ls
, muy probablemente gdb
vaya a segfaultear también. Podríamos escribir un wrapper para hacer esto, pero gdb
también lo soporta. Si iniciamos gdb
y después corremos set environment LD_PRELOAD=./malloc.so
antes de correr el programa, LD_PRELOAD
va a funcionar normalmente.
1 2 3 4 5 6 |
|
Como esperábamos, tenemos un segfault. Podemos usar list
para ver el código alrededor del error:
1 2 3 4 5 6 7 8 9 10 11 |
|
Y podemos usar p
(print) para ver qué está pasando con las variables:
1 2 3 4 |
|
ptr
vale 0
, o sea, NULL
, y esa es la causa del problema: nos olvidamos de chequear por NULL
.
Ahora que encontramos eso, probemos con un bug un poco más complicado. Digamos que decidimos reemplazar nuestra estructura por:
1 2 3 4 5 6 7 |
|
Y devolver block->data
en lugar de block+1
en malloc
, sin más cambios. Se parece bastante a lo que veníamos haciendo, sólo que ahora definimos un campo más que apunta al final de la estructura, y retornamos un puntero ahí.
Pero esto es lo que pasa si usamos nuestro nuevo malloc
:
1 2 3 4 5 6 7 8 9 10 |
|
Este no es tan lindo como el error anterior – podemos ver que uno de nuestros assert
s falló, pero gdb
nos deja tirados dentro de una función de printf
, llamada cuando un assert
falla. Pero esa función usa nuestro malloc
buggeado ¡y revienta!
Lo que podemos hacer es inspeccionar ap
para ver qué era lo que assert
trataba de imprimir:
1 2 |
|
Eso funcionaría bien. Podemos jugar un poquito hasta encontrar qué es lo que pretendía imprimir, y encontrar el error de ese modo. Otras formas hubieran sido implementar nuestro propio assert
o usar los hooks correctos para prevenir que assert
use nuestro malloc
.
Pero, en este caso, nosotros sabemos que hay sólo unos pocos assert
s en nuestro código: el de malloc
, garantizando que no estemos usandolo en un programa multihilo, y los dos de free
chequeando que no estemos liberando algo que no debiéramos. Miremos primero free
, poniendo un breakpoint:
1 2 3 4 5 6 7 8 |
|
Aún no asignamos block_ptr
, pero si hacemos s
un par de veces para avanzar hasta después de que fuera asignado, podemos ver que su valor es:
1 2 3 4 5 6 7 |
|
Estoy usando p/x
en vez de p
para poder verlo en hexadecimal. El campo magic
está en 0, que debería ser imposible para una estructura válida para liberar. ¿Puede que get_block_ptr
esté devolviendo un offset incorrecto? Tenemos ptr
disponible, así que podemos inspeccionar distintos offsets. Dado que es un void *
, vamos a tener que castearlo para que gdb
sepa cómo interpretar los resultados:
1 2 3 4 5 6 7 8 |
|
Si miramos un poco la dirección que estamos usando, podemos ver que el offset correcto es 24 y no 32. Lo que está pasando es que la estructura tiene padding, es decir, está siendo alineada al tamaño de palabra del procesador. Entonces, sizeof(struct block_meta)
es 32, incluso aunque el último campo válido esté en 24. Si queremos remover ese espacio extra, tenemos que arreglar get_block_ptr
.
¡Y eso fue todo el debugging!
Ejercicios
Personalmente, estas cosas no me quedan hasta que no hago un par de ejercicios, así que voy a dejar algunos acá para quienes les interese.
- Se espera que
malloc
devuelva un puntero convenientemente alineado a cualquier tipo nativo. ¿Hace eso el nuestro? Si lo hace, ¿por qué? Si no, corregilo. Notá que “cualquier tipo nativo” es, básicamente, 8 bytes en C, dado que los tipos de SSE/AVX no son nativos. - Nuestro
malloc
desperdicia un montón de espacio si reusamos un bloque sin necesitar tanto tamaño. Implementá una función que lo divida en bloques que ocupen el espacio mínimo necesario. - Después de hacer
2
, si llamamos amalloc
yfree
muchas veces con tamaños aleatorios, terminaremos con un montón de bloques pequeños que sólo se pueden reusar si pedimos cantidades pequeñas de memoria. Implementá un mecanismo que una bloques libres adyacentes para que varios bloques consecutivos se unan en uno solo. - ¡Encontrá bugs en el código existente! No lo testeé demasiado, así que tienen que haber bugs, por más que esto más o menos funcione.
Parte 2 en adelante
A continuación vamos a ver cómo hacer que esto sea más rápido y que sea thread safe.
Recursos
Leí este tutorial de Marwan Burelle antes de sentarme a escribir mi propia implementación, así que se parece bastante. Las principales diferencias son que mi versión es más simple, pero más vulnerable a la fragmentación de memoria. En términos de exposición, mi estilo es bastante más informal. Si querés algo más formal, el Dr. Burelle es tu camino a seguir.
Para saber más sobre cómo Linux maneja la memoria, mirá este post de Gustavo Duarte.
Para saber más sobre cómo funcionan las implementaciones reales de malloc
, dlmalloc y tcmalloc son dos grandes lecturas. No leí el código de jemalloc, y escuché que es un poco más difícil de entender, pero es una de las implementaciónes de [malloc] de alta performance más usadas que hay.
Para ayuda debuggeando, Address Sanitizer la rompe. Y si querés escribir una versión thread-safe, Thread Sanitizer también es una gran herramienta.
Agradecimientos
Gracias a Gustavo Duarte por permitirme usar una de sus imágenes para ilustrar sbrk
, a Ian Whitlock y Danielle Sucher por encontrar algunos typos, a Nathan Kurz por sus sugerencias sobre los recursos adicionales, y a “tedu” por encontrar un bug. Por favor, avisame si encontrás otros bugs en este post (tanto en el texto como en el código).