Portada arrow Artículos arrow Code, stack, data & heap en la ejecución de programas
Code, stack, data & heap en la ejecución de programas
domingo, 12 de noviembre de 2006

Durante la ejecución de un programa, se utilizan varias zonas de memoria bien diferenciadas para guardar los parámetros, el contexto de la ejecución, las variables locales, el código, etc.... Son la pila de llamadas (call stack), el área de datos dinámicos, conocida como el montón o montículo (heap), el área de datos estáticos y el área de código. En este artículo se describe de manera básica y genérica cómo son utilizadas estas zonas durante la ejecución de un programa.

Cuando se realiza un programa compilado compuesto de varias funciones (o métodos, si estamos utilizando un lenguaje orientado a objetos), los compiladores traducen cada función y su contenido a código máquina dependiente de la plataforma.

Cuando se inicia la ejecución de ese programa, el sistema operativo carga el código ejecutable en una zona de memoria que esté libre, y además, reserva al menos dos espacios más de memoria para que el programa pueda ejecutarse, y almacene allí los datos que necesite: son el stack y el heap.

En aquellos lenguajes que además, permiten la creación de variables globales (como C o Pascal), se reserva además una tercera zona llamada zona de datos, área de datos o algo similar.

Supongamos que vamos a ejecutar un programa, y en un momento dado sólo está cargado el sistema operativo en la memoria principal.

Cuando le pedimos al sistema operativo que ejecute el programa, se carga el código ejecutable en una zona libre de la memoria, y el sistema operativo, además, reserva:

  • Una zona de datos para las variables globales y las constantes. Como en tiempo de compilación se conocen perfectamente las variables globales y las constantes, se reserva el espacio justo y necesario. Ni un byte de más. Además, aquellas variables globales que sean inicializadas a un valor concreto y también las constantes, se les puede dar valor cuando se reserva la zona de datos.
  • Una zona para las llamadas (stack) que almacenará los parámetros de las funciones, las variables locales y los valores de retorno de las funciones (o métodos).
  • Una zona para la gestión dinámica de memoria (el heap). Es decir, aquella memoria que se solicita durante la ejecución del programa. Por ejemplo, en C con la orden malloc, en Pascal con getMem o en la mayoría de lenguajes más modernitos (Java, C#, C++ etc...) con new.

 

A partir de este momento, el programa está listo para ser ejecutado. El sistema operativo indicará a la CPU que para ejecutarlo debe apuntar con el registro del puntero de instrucciones (IP, instruction pointer) a la primera instrucción del código, y que debe apuntar con su puntero de pila (SP, stack pointer) a la primera dirección del área reservada para pila.

La gestión del heap y del area de datos suele quedar a cargo del compilador, que habrá introducido código para gestionarlas, y no de la CPU.

En otros casos, el sistema operativo no prepara un área de heap para cada aplicación, sino que él mismo gestiona un heap global utilizado por todas las aplicaciones que están en ejecución.

Todas las aplicaciones tienen un punto de entrada (la función main o similar). Cuando empieza la ejecución, el IP de la CPU apunta a la dirección de este punto de entrada.


LA PILA o STACK

Una pila es una estructura de datos en la cual, el último elemento en llegar es el primero en salir (a menudo este comportamiento se abrevia con las siglas LIFO.- Last Input First Output). Se llama así porque su comportamiento se asemeja a una pila de cosas. Por ejemplo, imagina una pila de platos iguales, uno sobre otro. La forma de añadir un plato nuevo a la pila es colocándolo encima. Cuando queremos retirar un plato también cogemos el de arriba.

La pila de llamadas también tiene este comportamiento. Cuando empieza la ejecución, la pila está inicialmente vacía. En el transcurso de la ejecución, seguro que se efectúan llamadas a otras funciones (o métodos). En cada llamada, en la pila se almacena lo siguiente:

  • La dirección a la que se debe retornar después de la llamada, es decir, la dirección de la instrucción siguiente a la propia llamada.
  • Los datos pasados como parámetros de la llamada.
  • Las variables locales utilizadas por la función llamada
  • Si la función devuelve algo, lo dejará en la pila también.

Por ejemplo, observa este sencillo código en C#

 
01    class Program
02    {
 
04        static int metodo1(int a)
05        {
06            int b = metodo2(a+6);
07            return a + b;
07        }
 
09        static int metodo2(int c)
10        {
11            return c - 3;
12        }
 
14        static void Main(string[] args)
15        {
16            Console.WriteLine(metodo1(43));
17            Console.WriteLine("Fin");
18        }
19    }

Tiene simplemente un par de métodos que se llaman unos a otro.

Cuando desde la función main se llama a metodo1, en la pila se almacenan... la dirección de retorno y el parámetro que se pasa

43
Dir. Retorno Línea 16

Cuando empieza la ejecución de método1, éste necesita un parámetro: a, que se encontrará en la pila, con valor a=43.

A su vez, método1 llama a metodo2, pasándole como parámetro un 49, resultado de 43+6. Por supuesto, antes de la llamada, deja la dirección de retorno en la pila.

49
Dir. Retorno Línea 06
43
Dir. Retorno Línea 16

Cuando empieza metodo2, éste necesita un parámetro, que irá a buscar a la pila. Encuentra el 49, entonces c=49.

Realiza la operación 49-3, y obtiene el resultado, 46, que es su valor de retorno.

Elimina el parámetro 49 de la pila.

Dir. Retorno Línea 06
43
Dir Retorno Línea 16

Obtiene la dirección de retorno de la pila (la línea 06), que elimina. Deposita el resultado (46) en la pila, y salta a la dirección de retorno (línea 06).

retorno 46
43
Dir. Retorno Línea 16

En la línea 6, continúa entonces metodo1, que estaba esperando el resultado de metodo2. Encuentra este resultado en la pila, y ya puede interpretar que b=46;

metodo1 ejecuta entonces la suma a+b. Ambos valores se encuentran en la pila (43+46=89).

Antes de finalizar metodo1, elimina sus variables y parámetros de la pila, lee la dirección de retorno, deposita en la pila su retorno, y salta a la dirección de retorno, la línea 16.

89

En la dirección de retorno, la línea 16, WriteLine está esperando el resultado de la llamada. Lo encontrará en la pila. Ya se puede imprimir.

 

 

Este mecanismo, algo más complejo en la realidad, pero en sus puntos básicos similar al descrito es el que se utiliza para la ejecución de funciones o métodos prácticamente siempre.

Las CPU son capaces de manejar una pila de llamadas para cada proceso en ejecución. Los programas compilados hacen uso de esos mecanismos de la CPU para realizar las llamadas.

En los programas interpretados, la CPU no ejecuta directamente el código, sino que lo hace el íntérprete, mientras que la CPU ejecuta al intérprete. En estos casos, la pila de llamadas es responsabilidad del intérprete, pero sigue teniendo un funcionamiento similar.

Debemos tener en cuenta que la pila de llamadas tiene una capacidad limitada. A priori no podemos saber cuál es, pero podemos estar seguros de que es limitada.

Durante la ejecución de programas, unos métodos (o funciones) llaman a otros, pasándoles parámetros y direcciones de retorno a través de la pila. Cada método llamado utiliza entonces la pila para almacenar sus variables locales y cuando terminan, las sacan de la pila, dejándo en ella únicamente el resultado de su ejecución.

En un instante dado, la pila almacena los datos de una serie de llamadas. Un método llama a otro, y éste a otro, y éste a otro, y así sucesivamente, es posible que la pila alcance su capacidad máxima. En ese caso, la ejecución no puede continuar, y se produce un error conocido como desbordamiento de pila o stack overflow. Es poco corriente que ésto ocurra en algoritmos iterativos, ya que cada llamada debe ser programada explícitamente por el programador, y sería raro que un programador necesitara un gran número de llamadas a métodos anidadas. Rara vez se suele superar la veintena. No obstante, sí es frecuente este error en los algoritmos recursivos, ya que ahí, un método termina por llamarse a sí mismo una y otra vez, y debe ponerse especial cuidado en que la profundidad de estas llamadas no sea demasiado grande, ya que la pila tiene un tamaño limitado y en principio, desconocido. Prácticamente cualquier lenguaje nos va a permitir una profundidad de llamadas bastante grande, muy superior a la veintena, pero desde luego no debemos asumir que la pila es ilimitada. Por este motivo, los algoritmos recursivos deben utilizarse con mucho cuidado. Fuera de un entorno puramente experimental o de aprendizaje no es conveniente utilizarlos.

 

Image El contenido de la pila de llamadas puede "verse", durante el proceso de depuración (debug) de muchos depuradores. Por ejemplo, hemos ejecutado el código descrito en esta página paso a paso con ayuda del depurador de Visual Studio Express, y le hemos pedido que nos muestre la pila de llamadas. Puedes ver el screencast haciendo click aquí.


DATOS ESTÁTICOS o DATA

Esta zona de la memoria es muy sencilla. Si en el stack se guardan los parámetros y las variables locales, porque no se sabe cuántos son durante la compilación, sólo se puede saber durante la ejecución, con los datos estáticos ocurre justo al revés.

Las variables globales y constantes son perfectamente conocidas durante la compilación. El compilador, cuando acaba la compilación conoce todas y cada una de ellas, y sabe perfectamente la cantidad de memoria que van a ocupar, así que en el ejecutable, escribe instrucciones para reservar esa memoria, y de dar los valores iniciales.

Quizá podríamos pensar que eso también ocurre con las variables locales. Bien... no es así. Una función (o método) en un instante dado puede estar en ejecución o no (las que tienen algo en la pila están ejecutándose y el resto no). Debemos tener en cuenta que en un instante dado es raro que estén en ejecución más de veinte funciones. Sólo esas necesitan espacio de memoria para sus variables y parámetros, mientras que un programa más o menos grande puede estar compuesto de varios cientos de funciones. Sería un desperdicio reservar memoria para las variables locales de cada función cuando no van a ser utilizadas nada más que si la función se ejecuta. Las variables globales pueden ser utilizadas en cualquier momento, por eso están disponibles en el área de datos estáticos desde el principio hasta el fin de la ejecución.

Además, si utlizamos recursividad, una función puede estar en ejecución varias veces en un instante dado, y para cada una de esas veces necesita un conjunto de variables locales y parámetros.


DATOS DINÁMICOS o HEAP

Más allá de los parámetros y variables locales y globales, muchas veces nos vemos en la necesidad de utilizar más memoria.

Durante la ejecución de cualquier método se puede solicitar la asignación de un bloque de bits, que utilizaremos para almacenar cualquier tipo de datos. El área de memoria que se destina a este cometido es conocida como heap, montón o montículo. (Ojo: no confundir con la estructura de datos heap. En principio, no tienen demasiado que ver).

El heap puede ser gestionado diréctamente por el compilador o intérprete, o es posible que lo gestione directamente el sistema operativo.

El caso es que los lenguajes disponen de órdenes para solicitar un bloque de memoria "al vuelo", mediante funciones o métodos como malloc (C), getmem (Pascal) o new (lenguajes OO).

Cuando se invoca uno de estos métodos o funciones, el compilador, intérprete o S.O. reserva memoria para nuestros programas. Si por ejemplo, solicitamos 200 bytes de memoria, se busca un área libre en el heap de 200 bytes, se marca como asignada y se nos devuelve la dirección de memoria del primero de esos bytes (mediante un puntero o referencia).

Nosotros haremos uso de esos 200 bytes almacenando ahí lo que necesitemos, con cuidado de no pasarnos.

Si nos pasamos y utilizamos más de los 200 bytes que nos han reservado pueden ocurrir varias cosas:

-Que escribamos en un area reservada para otra cosa, en cuyo caso, "machacamos" lo que hubiera, pudiendo causar mal funcionamiento en otro programa o en el nuestro.

-Que escribamos en un área libre. En principio no pasaría nada, pero en cualquier momento puede ser asignada para otro programa, o para el nuestro, y entonces "machacarían" nuestros datos.

Estas dos cosas pasan en sistemas relativamente antiguos.

En sistemas más modernos, la CPU y el S.O. (o un intérprete) nos impiden rebasar el área asignada, abortando la ejecución de nuestro programa o enviándonos una excepción.

Cuando ya no necesitamos la memoria que nos asignado dinámicamente, es conveniente informar de ello, para que pueda ser asignada a otro proceso o al nuestro si volvemos a solicitar memoria. Eso se hace con órdenes como free, freemem, dispose, etc... indicando la dirección de memoria que queremos librerar (mediante el puntero o referencia que nos dieron al principio)

En lenguajes como C o Pascal, se hace imprescindible devolver esta memoria, ya que si solicitamos memoria sin parar, y no la liberamos cuando terminamos con ella estamos propiciando que se agote. La memoria dinámica que se puede proporcionar a los programas es mucha, pero no hay motivo para malgastarla.

En lenguajes más modernos, como Java o C#, existe un mecanismo denominado recoletor de basura o garbage collector.

Es un mecanismo ingenioso relacionado con la gestión de memoria dinámica. Consiste en un proceso que se ejecuta en segundo plano, y que va buscando bloques de memoria no referenciados. Es decir, que no exista en el programa, en un instante dado un puntero o referencia que apunte a su dirección. Si es así, el bloque es marcado como libre.

Existen tanto defensores como detractores de la recolección de basura. Personalmente, me parece un invento fantástico. Los detractores de la recolección de basura (que son pocos) suelen argüir básicamente tres argumentos: 1) que consume recursos, 2) que hace más torpes a los programadores, porque no tienen que pensar en liberar la memoria que han solicitado y 3) que la memoria no se libera en el momento en el que ya no es necesaria, sino después, cuando el recolector se da cuenta de que ya no está siendo reclamada.


RESUMIENDO

Durante la ejecución de un programa, se utilizan varias zonas de memoria con propósito claramente diferenciado:

-La zona de código, que contiene las instrucciones a ejecutar
-La zona de datos estáticos, en los que se guardan variables globales y constantes
-La zona de pila de llamadas, en la que se guardan las direcciones de retorno en las llamadas a función (o invocaciones de método), los parámetros que se les pasan, las variables locales y los resultados de retorno de las funciones o métodos.
-La zona de datos dinámicos, o heap, que se utiliza para hacer asignaciones dinámicas de memoria, como las que se realizan con funciones u operadores como malloc (en C), getmem (en Pascal), o new (en los lenguajes O.O.)

Ten en cuenta que las explicaciones de éste artículo son tremendamente genéricas. Por supuesto, la realidad es algo más compleja, pero a grandes rasgos, se siguen las pautas descritas aquí. Por supuesto, también hay notables excepciones.

Las CPU actuales ayudan a los sistemas operativos con la gestión de estas cuatro zonas de memoria. Por ejemplo, no permitiendo a las aplicaciones que escriban en sitios donde no deben (sobre el código, sobre memoria asignada a otras aplicaciones...).

En los intérpretes, incluidas las máquinas virtuales, como la de Java, o la de .net, todo este montaje se realiza en el propio intérprete, y la CPU ejecuta al intérprete.

 

 
←Artículo anterior   Artículo siguiente→

Categorías

  • Ingeniería del software  ( 3 artículos )

    Acerca de la ingeniería del software y el ciclo de vida del software.

  • El programador elegante  ( 12 artículos )
    Una serie de artículos dedicados a buenas prácticas en programación
  • Opinión  ( 7 artículos )

    Artículos de opinión, no necesariamente fundamentada.

  • Básico  ( 12 artículos )

    Artículos básicos sobre temas básicos.

     

¿Quién está en línea?

 web tracker

Licencia Creative Commons Powered by Joomla! CMS Terminos de uso y formulario de contacto BloGalaxia

Suscríbete

RSS feed Sindicación RSS

(¿Qué es la sindicación RSS?)


Suscribir por e-mail

¿Dónde estoy?

Estás en La tecla de ESCAPE, un sitio web personal en el que nos gusta hablar de algoritmos, informática, tecnología, ciencia, ingeniería, internet... y cualquier tontería que se nos ocurra. El punto de vista de nuestros artículos técnicos suele ser muy básico, así que a menudo adoptamos grandes simplificaciones. (Más...-Términos de uso)