Disponible la nueva versión "donationware" 7.3 de OrganiZATOR
Descubre un nuevo concepto en el manejo de la información.
La mejor ayuda para sobrevivir en la moderna jungla de datos la tienes aquí.

Curso C++

[Home]  [Inicio]  [Índice]


1.3.2  Almacenamiento

Nota:  en este capítulo tratamos algunos aspectos lógicos del almacenamiento de un programa en una computadora (código y datos).  En el capítulo 2.2.6 se amplían algunos conceptos relativos al almacenamiento de los datos que incluyen detalles sobre el soporte físico utilizado.

§1  Preámbulo

Los aspectos concretos de almacenamiento de un programa dependen de la plataforma, no existe un modelo único.  Por ser con mucho el más extendido, nos referiremos aquí al modelo de almacenamiento típico de un programa ejecutable estándar (no una librería dinámica [0] ) en el entorno Windows 32. Modelo que, salvo algunos detalles, puede aplicarse a otras plataformas modernas.

§2  El proceso de carga

En el entorno Windows-32, cuando el Sistema carga un ejecutable (fichero-imagen) desde la memoria externa (disco), se inicia un proceso bastante complejo hasta que sus distintos módulos, que aquí se llaman segmentos, quedan alojados en las posiciones de memoria asignadas por el Sistema.

Nota: Los distintos segmentos no necesariamente resultan contiguos entre sí, pero las entidades de cada segmento sí están dispuestas de forma contigua dentro de ellos.

Para entender el proceso, recordemos que la máquina no entiende de conceptos tales como a = b + 1, para el procesador no existen las variables a o b. En consecuencia, la actuación combinada del compilador y el linker debe reducir la expresión anterior a algo como "copia en el registro R el contenido de las n posiciones de memoria a partir de la posición x (dirección de la variable b); súmale una unidad, y copia el resultado en n posiciones de memoria a partir de la posición z (dirección que corresponderá a partir de ahora a la variable a)".  Toda esta información se encuentra codificada en binario en el fichero imagen, pero el problema es que el enlazador no puede conocer de antemano en qué posiciones de memoria será cargado el programa para su ejecución (en realidad puede ocupar posiciones distintas en cada ejecución). En consecuencia, las posiciones x y z consignadas en el fichero imagen, no son posiciones absolutas sino relativas a una posición inicial que será asignada por el Sistema en el momento de la carga.

La consecuencia final es que, además del acomodo en memoria de los distintos módulos, la utilidad de carga debe recalcular las direcciones contenidas en la imagen, sumándoles un desplazamiento inicial ("offset") según el punto de inicio del segmento correspondiente.  Recuerde que en el ejecutable no existen identificadores (nombres), solo direcciones de memoria. Es frecuente utilizar la siguiente terminología:

  • Dirección base BA ("Base Address") de un fichero imagen cargado en memoria, es la dirección de inicio.
  • Dirección virtual relativa RVA ("Relative Virtual Address") de un objeto en un fichero imagen, es el valor de su dirección después de cargado en memoria menos la dirección base del fichero imagen.
  • Dirección virtual VA ("Virtual Address") de un objeto en un fichero imagen cargado en memoria, es el valor de su dirección. La razón de que se denomine "virtual" es porque el Sistema Operativo crea un espacio de direcciones distinto para cada proceso que corre en el sistema. Este espacio es independiente de la memoria física, ya que puede utilizar memoria virtual (en disco).

Montón local

(local heap)

Pila

(Stack)

Datos estáticos no inicializados
Datos estáticos inicializados
 

Código del programa

(code segment)


La disposición final de los módulos en  memoria sigue un esquema como el de la figura, pero en un SO moderno no debe pensarse en la imagen cargada en memoria como un todo indivisible.  De hecho, el segmento de código es bastante independiente del de datos (en la figura lo hemos separado deliberadamente).  Puede a su vez estar constituido por un solo trozo o por varios que pueden ser cargados en memoria de forma no simultanea, solo cuando se necesitan (carga bajo demanda).  Además, puede ocurrir que los distintos segmentos obtengan distintos permisos de uso por parte del Sistema Operativo. Por ejemplo, la aplicación propietaria puede tener permiso de solo lectura ("read-only") sobre el segmento de código, y de lectura-escritura para el resto.  Sin embargo, en las librerías dinámicas (compartidas), el segmento de código puede tener autorización de lectura para cualquier proceso que lo solicite..

§3  Segmento de código

En la parte más baja del espacio ocupado por la aplicación se sitúa el código del programa, denominado también segmento de código ("Code segment") contiene el código (algoritmo) ejecutable y todos los valores que han podido ser resueltos en tiempo de compilación, como es el caso de algunas constantes que no reciben un Lvalue.  Recordemos que en esta zona está el código de todas las funciones (incluyendo las funciones-clase 4.11.5), y que los punteros-a-función señalan precisamente direcciones de este segmento.

En realidad no necesariamente existe un segmento de código para cada instancia del programa en ejecución. Por razón de eficiencia y economía de espacio, existen casos como el de las mencionadas librerías dinámicas (DLLs), en los que varias instancias de un mismo programa comparten el mismo segmento de código (por supuesto cada instancia tiene su propio segmento de datos). Cuando ocurre esto se dice que el código es reentrante y puede ser utilizado simultáneamente por varias instancias sin peligro de corrupción debido precisamente a la independencia de los datos de cada una de ellas.

§4  Segmento de datos

Donde termina el segmento de código, comienza una zona destinada a datos, es el denominado segmento de datos ("Data segment") o memoria local, aunque tampoco debe pensarse en el segmento de datos como un todo de tamaño fijo e indivisible.  Está organizado en cuatro subzonas que no necesariamente son contiguas (puede que lo sean), con características particulares y distintas en función del tipo de dato y cómo son manejados por el programa. Durante el proceso de carga, el Sistema les asigna el espacio necesario dentro de la memoria total disponible. A continuación, cuando se inicia el proceso, estas zonas son inicializadas.

Las zonas de datos estáticos permanecen constantes en tamaño a lo largo de la vida del programa, precisamente porque aquí se guardan variables y constantes cuyo tamaño es conocido en tiempo de compilación. Pero la pila y el montón, pueden variar de tamaño durante la ejecución porque guardan objetos que son conocidos en runtime.  En el primer caso (la pila) porque ahí se guardan los valores de las funciones, y en el caso de la invocación de funciones con gran nivel de anidamiento (  4.4.6b) esta estructura puede crecer indefinidamente.  En el caso del montón, puede ocurrir que en tiempo de ejecución el proceso demande más memoria para datos persistentes, espacio que más adelante puede ser liberado de nuevo (§3.4 ).  En ambos casos, el tamaño de estas zonas puede aumentar y disminuir, y consecuentemente, la memoria total demandada al Sistema por el proceso en ejecución.

Nota: piense que un Sistema Operativo moderno asigna espacio según demandan sus aplicaciones [4].  Cuando no hay memoria interna disponible, la simula mediante un mecanismo de memoria virtual ( H5.1), que utiliza el disco para estos menesteres.  Teóricamente el Sistema puede proporcionar memoria a una aplicación mientras exista espacio en disco,  pero también puede prevenirse que una aplicación no pueda asignar memoria por encima de un cierto valor (HEAPSIZE 1.4.4a).

La subzona que le corresponde a un dato concreto depende de las siguientes características:

  • Que el dato sea conocido en tiempo de compilación o en runtime.

  • Que el dato sea persistente (estático) o se destruya cuando el programa sale del bloque en que fue definido (automático)

Nota:  algunas características de los elementos descritos en este capítulo, por ejemplo su tamaño, pueden ser establecidos mediante las órdenes oportunas al enlazador ("Linker" 1.4).  Ver al respecto: Fichero de definición ( 1.4.4a).

§4.1  Datos estáticos

Son objetos cuya vida se extiende durante todo el programa.  Tienen existencia semántica y física (disponen de espacio de almacenamiento) antes que el programa inicie su ejecución y terminan al finalizar este.  Corresponden a entidades declaradas fuera de cualquier función (ámbito de fichero) o de ámbito de función que han sido declaradas estáticas.  Pueden ser de cualquier tipo y se almacenan en una zona especial del segmento de datos.  Serían el caso de los objetos señalados en el ejemplo:

static int x = 12; // Estática iniciada

static int y;      // Ídem no iniciada

int X = 10;        // Ok. también estática iniciada

int Y;             // Ídem no iniciada

...

void foo (int x) {

    static int n1 = 22;   // Estática iniciada

    static int n2;        // Estática no iniciada

    ...

}

Nota:  aparte de los "estáticos", existe otro tipo de datos persistente, que son almacenados en otro sitio y declarados de modo distinto.  Son los objetos del montón que se describen más adelante .  Aparte de la zona de almacenamiento y su forma de declaración, se diferencian de los estáticos en que, aunque son de duración indefinida, esta es iniciada y cancelada por el programador a voluntad.  Resulta así que desde el punto de vista de la persistencia, en los programas C++ se distinguen tres tipos de entidades:

  • Objetos automáticos:  Se almacenan en la pila y tienen duración de bloque de sentencia. Se dice de ellos que tienen almacenamiento dinámico.

  • Objetos estáticos: Tienen la misma duración que el programa.  Se dice de ellos que tienen almacenamiento estático.

  • Objetos del montón. Tienen duración entre dos instantes a voluntad del programador:  el momento de su creación y el de su destrucción.  Se dice de ellos que tiene almacenamiento asignado ("Allocated storage").

 §4.1a  Datos estáticos inicializados

Esta primera zona contiene datos estáticos inicializados.  Es decir, que reciben un valor explícito en el código del programa.  Suelen ser cadenas literales y entidades numéricas, aunque pueden ser de cualquier tipo.  Es también el caso de objetos estáticos (instancias de clases) cuya inicialización corre a cargo del constructor correspondiente.  Por ejemplo, los iostreams de la Librería Estándar C++ de E/S ( 5.3)

§4.1b  Datos estáticos no inicializados

Análogos a los anteriores pero sin inicialización.  Hemos indicado que en cualquier caso, dispongan de inicialización explícita o no, los objetos estáticos tienen existencia física antes que se inicie la ejecución del programa.  A este respecto, el Estándar establece que todas las variables estáticas que no hubiesen sido inicializadas se inicializan a 0 cuando el segmento de datos es creado en memoria.

Nota:  Cuando en los textos informáticos se dice coloquialmente "guardar datos en el segmento", se refieren a alguna de estas dos zonas de datos estáticos.

§4.2  Pila  ("Stack")

Es un área muy importante manejada directamente por la UCP para alojar datos durante la ejecución del programa.  Su nombre deriva de su propio mecanismo de funcionamiento.  Es un almacén de datos contiguos del tipo LIFO ("Last In First Out" 1.8).  Frecuentemente es comparada con una pila de platos; el último en ser colocado es el primero en ser retirado.

Se usa para muchas cosas, por ejemplo, aquí se almacenan las variables locales automáticas y los datos involucrados en el mecanismo de invocación de funciones, de forma que si se utilizan muchas de estas invocaciones forma anidada o recursiva, la pila crece.  En algunos sistemas, la pila y el montón son contiguos y el crecimiento desmesurado de la pila puede llegar a sobrescribir el área inferior del montón.

  Más información al respecto en: ( 4.4.6b  Carga y descarga de funciones).

Los movimientos en el stack son generalmente rápidos, a veces basta una simple instrucción del procesador para almacenar o borrar algo en la pila.  Los objetos colocados en ella se asocian a una duración automática .  El término se refiere a que es el compilador el que determina cuando se destruyen.  El lenguaje C++ se caracteriza por hacer un uso extensivo de la pila (muchos objetos son "automáticos" por defecto) y el mecanismo de invocación de funciones se basa en su utilización.  Decimos que C++ es un lenguaje orientado a la pila.

La localización y desalojo de variables de la pila se realiza de forma automática (son decisiones tomadas por el compilador), no obstante, la directiva register ( 4.1.8b) permite indicar que algunas variables que normalmente irían en esta zona, sean alojadas en los registros del procesador.

Precisamente este "automatismo" hace que la llamada explícita al destructor de objetos que hayan sido construidos en esta zona sea extremadamente peligroso, ya que si se realiza antes de que el objeto salga de ámbito, el destructor será llamado de nuevo cuando sea liberado el marco de la pila correspondiente a dicho ámbito.  A cambio la pila presenta la comodidad que supone la destrucción automática de los objetos alojados en ella cuando salen de ámbito (con la liberación de la memoria correspondiente), lo que supone que no hay peligro de pérdidas inadvertidas porque el programador olvide destruir el objeto.  Es el siguiente caso:

class MiClase { ......}

...

{        // un ámbito cualquiera...

    ....

    MiClase objeto1;

    ....

}        // Ok. objeto1 es destruido al llegar a este punto

§4.3  Montón local ("Local heap")

Es un área fija de memoria, asignada en runtime por las rutinas de inicio antes de que comience la ejecución de main ( 4.4.4).  Se usa para la asignación dinámica de memoria.  Por ejemplo, cuando pedimos al programa que asigne memoria mediante malloc, o cuando creamos un nuevo objeto con el operador new ( 4.9.20).  Por razón de su origen y comportamiento, a los objetos almacenados es esta zona se les denomina "objetos del montón" ("Heap objects"), "de memoria dinámica" ("Dynamic memory") o que están en "almacenamiento libre" ("Free store").

Las asignaciones de memoria del montón son generalmente más lentas que las de pila. Además los objetos situados en este área tienden a ser persistentes [3]; se mantienen hasta que el programador decide su destrucción, con la liberación consiguiente de la memoria previamente asignada; por ejemplo con una llamada al destructor de una clase, la función free o la palabra delete.

Nota: esta lentitud en el manejo de los objetos del montón es determinante en el rendimiento del programa, de forma que la agilidad total de un compilador dependen grandemente de su eficacia en el manejo de la memoria dinámica.


El uso de la pila o del montón supone una elección de prioridades entre velocidad de creación, de liberación de almacenamiento y automatismo, frente a un mayor control.  En algunas circunstancias estos asuntos son de tener en cuenta.  Puede ser conveniente sacrificar la flexibilidad y automatismo (que ofrece la pila) porque prefiramos controlar exactamente el tamaño y tiempo de vida de un objeto (cualidades que ofrece el montón).

En C clásico existen varias funciones de librería que permiten asignar y rehusar espacio en el montón; son las funciones malloc( ), calloc( ), realloc( ), y free( ) que están también disponibles en C++, aunque este tiene un sistema propio de más fácil uso con las palabras clave new y delete.

 §4.3.1  Inconvenientes del montón

El mecanismo proporcionado por C++ para manejo de esta zona de memoria es muy simple:  podemos afirmar que básicamente se limita a asignar memoria con new y a rehusarla con delete. Sin embargo esta simplicidad paga un precio:  Existen dos inconvenientes que pueden causar grandes problemas y quebraderos de cabeza a los programadores C++.  Nos referimos a los peligros de pérdidas y de fragmentación de memoria.

El primero se presenta cuando el programador olvida rehusar algún trozo de memoria previamente asignado cuando ya no es necesario.  Como el compilador no tiene por sí mismo noticia de la duración de los objetos alojados en el montón, es el programador el que debe ocuparse de su destrucción cuando no sean ya necesarios.  De lo contrario, si se olvida de hacerlo se producirán pérdidas de memoria; zonas de memoria no utilizadas pero que el programa las considera no utilizables.  Es el caso del siguiente ejemplo:

class MiClase { ......}

...

{       // un ámbito cualquiera...

    ....

    MiClase* obj1Ptr = new MiClase;

    ....

}       /* Peligro!! pérdida irremediable de memoria a menos que se

        incluya la sentencia delete obj1Ptr; antes del corchete de cierre */


No piense el lector que puede curarse fácilmente de este peligro teniendo simplemente "buena memoria", es decir, acordándose de destruir todos los objetos previamente creados.  Existen circunstancias insidiosas contra las que se requiere cierta perspicacia y entrenamiento.  Para caer en la trampa pueden bastar estas "inocentes" sentencias (ver una explicación en 3.2.3f):

char* a="Capitulo-1";
char* b="Capitulo-2";
...

a=b;

En otros casos el motivo de la pérdida es un poco más sofisticado (ver ejemplo: 4.11.2d2).


Como consecuencia, las pérdidas "misteriosas" de memoria son muy frecuentes en los programas C++.  En especial el uso de punteros en procesos de lógica muy intrincada puede ser una auténtica pesadilla en cuanto a su depuración.  Aunque raros, existen algunos programas comerciales que ayudan al programador en la investigación de este tipo de problemas, son denominados "Leakage detectors" en la literatura inglesa [1].  Además, la Librería Estándar C++ ha adoptado también alguna medida al respecto (Punteros inteligentes 4.12.2b1).

Nota:  en algunos lenguajes como JAVA o C#, donde al contrario que en C/C++ la velocidad no es un asunto primordial, esto se evita con el recolector de basura (GC "Garbage collector"), que se ocupa de liberar esta memoria cuando comprueba que no será más necesaria.  Sin embargo esta "comodidad" tiene un costo en tiempo de ejecución.  Aunque C++ permite que uno pueda construir su propio recolector de basura [2], no es una característica incluida en el lenguaje de forma estándar, porque como hemos señalado en múltiples ocasiones, la rapidez de ejecución es una de sus premisas de diseño.


El segundo inconveniente es la fragmentación cuando se solicitan y rehúsan sucesivamente muchos trozos de memoria.  En estos casos puede ocurrir que en algún momento no exista un trozo de memoria contigua suficientemente grande como para alojar un objeto de un tamaño determinado (algo parecido a lo que ocurre con la fragmentación de disco).

La solución al problema son los denominados compactadores del montón ("Heap compactor") que en algunos lenguajes se preocupan de mantener juntos y contiguos los trozos ocupados, manteniendo contigua la zona libre.  Naturalmente esto tiene también su costo en tiempo de proceso, pues requiere que mientras se realizan los movimientos de bloques de memoria (que se realiza en un hilo -thread- de segundo plano 1.7), se congele la actividad del programa, en especial el uso de punteros a tales zonas, que deben ser reasignados después de los movimientos.  Para conseguirlo, la memoria se utiliza a través de los denominados manejadores de memoria ("Memory handles"), y del mencionado mecanismo de bloqueo, que alterna la actuación del compactador con la posible utilización de punteros.

Para mitigar los problemas antes mencionados, existen librerías comerciales para C++ bajo el nombre genérico de gestores de memoria ("Memory managers").  Son herramientas de runtime, que ayudan a prevenir y detectar algunos errores corrientes en el uso del montón.  Por ejemplo, escribir más allá del final de la memoria correspondiente a un objeto, olvidar destruirlo, o intentar borrarlo dos veces [5].


§4.3.2  En realidad hay varios "montones" (heaps) según se considere la cuestión a nivel local (de aplicación) o a nivel global (del Sistema Operativo).  El mecanismo de funcionamiento (para BC++) y la nomenclatura utilizada en cada caso son como sigue:

  Local heap: (montón local), al que hemos hecho referencia ; zona de memoria disponible solo para nuestra aplicación o librería.

El máximo de bloques de memoria que pueden situarse en el montón local es de 64K menos el tamaño de la pila (stack) y de las zonas de variables globales (estáticas).  Por esta razón el montón local está mejor adaptado para bloques de memoria pequeños (de 256 bytes o menos).  El tamaño por defecto para el montón local es 8 K, pero puede cambiarse (en el fichero .DEF).

Observe que este límite se refiere a 64K bloques, no 64K Bytes.  De hecho los bloques pueden ser todo lo grandes que se necesite siempre que exista memoria disponible.  Cuando la memoria física se agota, el sistema signa memoria virtual , pero cuando el montón es muy grande, el sistema se ralentiza notablemente, ya que el método de asignación de memoria dinámica del compilador es básicamente un mecanismo de búsqueda de un bloque libre (del tamaño adecuado para la demanda del momento), en una cadena en la que se alternan zonas libres y ocupadas.

  Global heap: (montón global),  zona de memoria disponible para todas las aplicaciones.

Aunque el montón global puede contener bloques de cualquier tamaño, está pensado para bloques grandes (de 256 bytes o más).  Bajo el sistema Windows, en modos normal y 386 extendido, cada bloque del montón global necesita de un extra de al menos 20 bytes. En este sistema existe además una limitación de 8192 bloques para el montón global, de los cuales solo algunos están disponibles para una aplicación determinada.

Nota:  el compilador BC++ puede subdividir bloques del montón global en trozos más pequeños a fin de reducir la posibilidad de alcanzar el límite del sistema ( HeapLimit y HeapBlock).

  heap suballocator:  Cuando en un programa, el manejador del montón (heap manager) asigna un bloque grande de memoria, simplemente asigna un bloque del montón global (global heap) utilizando la rutina GlobalAlloc de Windows.

En cambio, cuando se asigna un bloque pequeño, el "heap manager" de BC++ asigna un bloque grande del montón global y lo subdivide en bloques más pequeños según la necesidad.   El mecanismo de reasignación de estos bloques pequeños reutiliza todo el espacio disponible antes de que el manejador del montón tenga que asignar un nuevo bloque del montón global (que a su vez es nuevamente subdividido).

  Inicio.


[0]  Existe un tipo especial de ejecutable, las Librerías Dinámicas ( 1.4.4b), cuyo esquema de almacenamiento tiene ciertas peculiaridades respecto a lo aquí señalado.

[1]  A título de ejemplo podemos citar a Rational    http://www.rational.com/, que produce herramientas de ayuda al desarrollo, entre las que se encuentra PurifyPlus, un depurador que permite controlar este tipo de problemas.  También Valgrind  http://developer.kde.org/~sewardj/, un depurador de memoria de código abierto (GPL) para x86-GNU/Linux que también puede ser utilizado en desarrollos Windows.

[2]  Desde luego la construcción de un recolector de basura automático para un programa C++ no es una tarea para principiantes, pero existen algunas versiones comerciales suficientemente probadas.  Ver al respecto el sitio de Hans-J. Boehm     Página personal de Hans, la sección dedicada a "Garbage collection" puede ser un buen punto de inicio para los interesados en el tema.  También puede consultarse la página de The Code Proyect, en la que se muestra uno de estos programas   www.codeproject.com

[3]  Aunque en la literatura inglesa se utilizan con profusión los términos "memoria dinámica", "memoria libre" y "objetos dinámicos" para designar a los objetos almacenados en esta zona de memoria, las expresiones nos parecen desafortunadas.  Por esta razón, a los objetos alojados en el montón preferimos designarlos como objetos persistentes, ya que a nuestro juicio, este calificativo describe mejor las características de duración de tales objetos.

[4]  Gran parte del trabajo de un Sistema Operativo está relacionado con el manejo de la memoria.  Su eficiencia en este sentido, tanto para el manejo de la memoria interna (RAM) como la externa (sistema de ficheros utilizado), determina de forma principalísima la calidad total del mismo.

[5]  Se afirma que el 99 por ciento de los denominados errores de memoria ("Memory bugs") son causados por una mala utilización de objetos en el montón.