miércoles, 8 de septiembre de 2010

Anatomía de un programa en memoria.

                           Nota previa:
Esta es la segunda entrega (traducción) de los trabajos de Gustavo Duarte. Se llama (como ya leyeron en el título) "Anatomía de un programa en memoria".  El original salió el 27 de enero del 2009 y lo pueden encontrar aquí. Van a encontrar conceptos muy interesantes como la "aleatorización" del espacio de direcciones y una descripción muy interesante de la segmentación y organización de memoria. Ojo, es muy probable que continúe traduciendo los trabajos de Duarte, lo que no les garantizo es que lo vaya a hacer cronológicamente. Sin más ahí les va, que lo disfruten .......




 Anatomía de un programa en memoria.

La administración de memoria es el corazón de los sistemas operativos; es crucial tanto para la programación como para la la administración del sistema. En los siguientes posts, estaré abordando la memoria enfocado a los aspectos prácticos, pero sin dejar a un lado la implementación interna. Aun cuando los conceptos son genéricos, los ejemplos son tomados de Linux y Windows en sistemas de 32 bits (i386). Este primer post describe como están organizados los programas en memoria.

Cada proceso en un sistema operativo multitarea se ejecuta en su propia "caja-de-arena" (sandbox en el original y en lo adelante) (sandbox, jails o incluso chroot son términos entendibles por los que vienen del mundo UNIX, para los que no, mas o menos refieren entornos de operación limitados, si quieren mas info al respecto pueden revisar sandbox, jail y chroot ). Este sandbox es el espacio virtual de direcciones, el cual, en modo de 32 bits siempre es el bloque de 4 Gb de direcciones de memoria. Estas direcciones virtuales son mapeadas a direcciones físicas mediante tablas de páginas, mantenidas estas por el kernel del sistema operativo y consultadas por el procesador. Cada proceso tiene su propio set de tablas de páginas, pero aquí surge un problema. Una vez que son habilitadas las direcciones virtuales, estas son aplicadas a todo el software que esta siendo ejecutado en la máquina, y esto incluye al kernel mismo. De modo que  una porción del espacio de direcciones virtuales deberá ser reservado para el kernel. 

Esto no significa que el kernel use tanta memoria física, sino que tiene esta porción del espacio de direcciones disponible para ocupar la cantidad que desee. El espacio perteneciente al kernel queda marcado (flagged en el original es decir bandera, que es una manera de indicar que un recurso esta siendo usado y no esta disponible (de manera general, las banderas tienen otros propósitos además de....)) como exclusivo para código privilegiado (anillo 2 o inferior), de modo que se disparará un fallo de página (page fault en el original y en lo adelante) si algún programa en modo-usuario trata de accederlo. En Linux, el espacio del kernel está siempre presente y mapea la misma memoria física para todos los procesos. Los datos y el código del kernel son direccionados constantemente, listos para atender interrupciones o llamadas al sistema en cualquier momento. En contraste, el mapeo de la parte del espacio de direcciones correspondiente a modo-usuario cambia cada vez que ocurre una conmutación (de procesos).


Las regiones en azul representan direcciones virtuales mapeadas en memoria física, mientras que las regiones en blanco no están mapeadas. En el ejemplo de arriba, Firefox ha usado más allá de su espacio de direcciones virtuales debido a su legendaria avidez de memoria (paliable, en parte (y bajo ciertas condiciones) seteando valores como config.trim_on_minimize=true por si les interesa, hay varios modos). Las distintas bandas en el espacio de direcciones corresponden a segmentos de memoria como el "montón" (heap en el original y en lo adelante, refiere el espacio usado para direccionamiento dinámico), la pila (stack en el original y en lo adelante, refiere a la estructura de datos clásica de LIFO, Last In First Out el último que entra es el primero que sale) y así. Tenga presente que estos segmentos son simplemente rangos de direcciones en memoria y no tienen que ver con los segmentos clásicos de Intel. De igual manera aquí tienen la estructura de segmentos estándar en los procesos en Linux.


Cuando las cosas en este negocio eran felices, la dirección virtual inicial de los segmentos mostrados arriba era exactamente la misma para casi todos los procesos en una PC. Eso originó que fuera fácil explotar ciertas vulnerabilidades de manera remota. Un exploit (no encuentro una traducción feliz para el término, así que si lo necesitan, vayan por la referencia) frecuentemente necesita referenciar localizaciones absolutas de memoria: una dirección en el stack, la dirección de una función en una librería, etc. Un atacante remoto debía elegir esta localización a ciegas, contando con que los espacios de direcciones tuvieran igual estructura. Si así era, la gente terminaba fregada (pwned en el original, vean la referencia). De este modo se volvió popular la "aleatorización" del espacio de direcciones. Linux aleatoriza el stack, los segmentos del mapa de memoria y el heap adicionándoles un desplazamiento (offset en el original y en lo adelante, aquí la referencia) a sus direcciones de inicio. Por desgracia el espacio de direcciones en un sistema de 32 bits es bien angosto, dificultando (hampering en el original, e incluye el enlace .......... que esta en inglés :-P) la aleatorización.

El segmento superior en el espacio de direcciones de procesos es el stack, que almacena variables locales y parámetros de funciones en la mayoría de los lenguajes de programación. La llamada a un método o función empuja un cuadro nuevo en el stack. Este cuadro es destruido cuando se regresa de la función. Este diseño simple, posible gracias a que los datos obedecen estrictamente el orden LIFO, significa que no serán necesarias estructuras complejas de datos para seguirle la pista al contenido del stack - un puntero simple a la parte superior del stack será suficiente. Empujar y sacar (push & pop en el original son mnemónicos de ensamblador, instrucciones de hecho) son entonces una manera muy rápida y determinística. Además, el reuso constante de regiones del stack tiende a mantener activa la memoria de stack en el cache del CPU, acelerando el acceso. Cada hilo en un proceso tiene su propio stack.

Es posible agotar el área que mapea el stack empujando mas dato del que puede soportar. Esto disparará un fallo de paginado que es manipulado por Linux mediante la funcion expand_stack(), que a su vez llama a la funcion acct_stack_growth() para comprobar si será apropiado agrandar el stack. Si el tamaño del stack está por debajo del límite RLIMIT_STACK (usualmente 8 Mb),  entonces normalmente el stack aumentará su tamaño y el programa seguirá ejecutándose felizmente, sin saber lo que sucedió. Este es el mecanismo normal mediante el cual el tamaño del stack se ajusta a la demanda. Aun así, si el tamaño máximo del stack ha sido alcanzado, tendremos un desbordamiento de stack (stack overflow en el original y en lo adelante) y el programa recibirá un fallo de segmentación (segmentation fault en el original y en lo adelante). El área máxima mapeada de stack se puede expandir para cubrir la demanda, pero no se puede encoger. Como el presupuesto federal, solo se expande.
El crecimiento dinámico del stack es la única situación en la que el acceso a regiones no mapeadas de memoria, mostradas en blanco mas arriba, puede ser válido. Cualquier otro acceso a memoria que no esta mapeada disparará un page fault que resultará en un segmentation fault (segfault). Algunas áreas mapeadas son de solo lectura,  de modo que cualquier intento de escritura a esas áreas llevará tambien a un segfault.
Debajo del stack, tenemos el segmento de memoria mapeada. Aquí el kernel mapea el contenido de los archivos directamente en memoria. Cualquier aplicación puede solicitar localizaciones en esta área (en Linux) a través de la llamada al sistema mmap() (aquí la implementación) o (en Windows) de CreateFileMapping() / MapViewOfFile(). El mapeo de memoria es una manera conveniente y de alto rendimiento de implementar la entrada / salida (I/O en el original y en lo adelante) a archivos, de manera que es usada para cargar librerías dinámicas. También es posible crear un "mapeo de memoria anónimo" que no corresponde a ningún archivo que este en uso en vez de datos de programa. En Linux si solicitas un bloque de memoria grande vía malloc(), la librería de C creará esa clase de mapeo anónimo en vez de usar la memoria del heap. "Grande" significa mayor que MMAP_THRESHOLD bytes, 128 Kb por defecto y ajustable vía mallopt().

Hablando del heap, y viene a cuento en nuestro espacio de direcciones. El heap proporciona localización de memoria en tiempo de corrida (como el stack), que es usado para datos que deban sobrevivir a la función que ejecuta la localización (a diferencia del stack). La mayoría de los lenguajes proporcionan administración de heap a los programas. De esta manera satisfacer solicitudes de memoria es un asunto resuelto de manera conjunta entre el kernel y el lenguaje en tiempo de corrida. En C, la interface para localización del heap es malloc() (y compañía), mientras que en lenguajes con colectores de basura como C# la interface es la palabra clave "new".
Si hay espacio suficiente en el heap para satisfacer solicitudes de memoria, puede ser atendido por el lenguaje en tiempo de ejecución sin intervención del kernel. De otra manera el heap es agrandado a través de la llamada al sistema brk() (implementación) para hacer espacio para el bloque requerido. La administración del heap es compleja y requiere algoritmos sofisticados que se esfuerzan por hacer un uso eficiente y rápido de la memoria ante los caóticos patrones de localización de nuestros programas. El tiempo requerido para atender una solicitud de heap puede variar sustancialmente. Los sistemas de tiempo real poseen localizadores de propósito especial para lidiar con este problema. El heap tambien puede fragmentarse, como muestra la imagen a continuación:


Finalmente llegamos a los segmentos inferiores de la memoria: BSS (Block Started by Symbol), segmento de datos (data en lo adelante) y segmento de texto del programa. Ambos BSS y data almacenan contenido de variables estáticas (globales) en C. La diferencia es que BSS almacena el contenido de las variables estáticas sin inicializar, cuyos valores no son seteados por el programador en el código fuente. El área de memoria BSS es anónima: no mapea ningún archivo. Si dices (es un ejemplo) static int cntActiveUsers, el contenido de cntActiveUsers residirá en el BSS.
El segmento de datos, por otro lado, almacena el contenido de las variables estáticas que (si) han sido inicializadas en el código fuente. Esta área de memoria no es anónima. Mapea la parte de la imagen binaria del programa que contiene los valores estáticos iniciales definidos en el código fuente. Así que si dices static int cntWorkerBees = 10, el contenido de cntWorkerBees residirá en el segmento de datos y tendrá un valor de 10. Aun cuando el segmento de datos mapea un archivo, es mapeo de memoria privado, lo que significa que las actualizaciones de memoria no serán reflejadas al archivo que subyace. Así tiene que ser, de otra manera las asignaciones de variables globales cambiarían la imagen binaria en tu disco y eso es inconcebible.

El ejemplo de datos en el diagrama es más complicado porque emplea un puntero. En este caso, el contenido del puntero "gonzo" - una dirección de memoria de 4 bytes - reside en el segmento de datos. La cadena real a la que apunta, por el contrario, no (no reside en el segmento de datos). La cadena reside en el segmento de texto, el cual es de solo lectura y almacena todo el código además de golosinas como  las cadenas literales. El segmento de texto también mapea tu archivo binario en memoria, pero escribir en esta área le valdrá a tu programa un segfault. Esto ayuda a prevenir errores (bugs en el original) de punteros, a pesar de que no es tan efectivo como evitar (usar) C en primer lugar. Aquí hay un diagrama que muestra los segmentos y las variables de nuestro ejemplo.


Ud puede examinar las áreas de memoria de los procesos en Linux leyendo el archivo /proc/pid_del_proceso/maps (a estas alturas del post los no iniciados deberían de haber notado que el fylesystem "/proc" en Linux alguna gracia tiene...). Tenga presente que un segmento puede contener muchas áreas. Por ejemplo, cada archivo mapeado en memoria normalmente tiene su propia área en el segmento mmap y las librerías dinámicas (dlls para Windows) tienen áreas extras similares a BSS y datos. En el siguiente post aclararemos que significa en realidad "área". A veces la gente dice "segmento de datos" queriendo decir datos + bss + heap.

Ud puede examinar imágenes binarias usando los comandos nm y objdump para mostrar los símbolos, sus direcciones, segmentos etc. Finalmente la estructura virtual de direcciones descrita arriba es la estructura "flexible" en Linux, la cual ha sido la usual desde hace varios años. Asume que tenemos un valor para RLIMIT_STACK. Cuando no es el caso, Linux regresa al la estructura clásica mostrada abajo:


Y esto es todo sobre la estructura del espacio virtual de direcciones. El siguiente post discutirá como el kernel mantiene el control de estas áreas de memoria. Luego volveremos a el mapeo de memoria, como se relaciona con esto la lectura y escritura de los archivos de memoria y que significan las áreas de memoria.




Notas finales:
Como ven este segundo post, tiene bastantes referencias externas y por desgracia no todas están en castellano. Las que corresponden a artículos del mismo autor poco a poco serán traducidas para ustedes (las que no, si es que llegan no será poco a poco), algunas páginas que indico en wikipedia tienen versiones en inglés que muchas veces son mas completas que en castellano, les toca verificarlas, tarde o temprano tendrán que aprender (el de Shakespeare X-P). Cuando les digo " xxxxx en el original y en lo adelante" es porque hay términos que prefiero utilizar en inglés porque traducidos pierden un poco de sentido, pero igual y acepto opiniones. Este tipo de materiales, como son más o menos extensos y de digestión lenta es preferible marcarlos, para usos posteriores, así que si no lo han hecho ya, regístrense en delicious, vale la pena.     
(*) El termino ELF en el segmento de texto refire a executable and linkable format. Pueden verificar la referencia.


Espero les sea útil, provecho y hasta el próximo.
Enrique.