c++ - ¿Qué significa realmente "memoria asignada en tiempo de compilación"?
memory memory-management (12)
En lenguajes de programación como C y C ++, las personas a menudo se refieren a la asignación de memoria estática y dinámica. Entiendo el concepto, pero la frase "Toda la memoria fue asignada (reservada) durante el tiempo de compilación" siempre me confunde.
La compilación, como yo lo entiendo, convierte el código C / C ++ de alto nivel al lenguaje de la máquina y genera un archivo ejecutable. ¿Cómo se "asigna" la memoria en un archivo compilado? ¿La memoria no siempre está asignada en la RAM con todas las cosas de gestión de memoria virtual?
¿La asignación de memoria no es, por definición, un concepto de tiempo de ejecución?
Si hago una variable asignada estáticamente de 1 KB en mi código C / C ++, ¿aumentará eso el tamaño del ejecutable en la misma cantidad?
Esta es una de las páginas donde la frase se usa bajo el título "Asignación estática".
Volver a lo básico: asignación de memoria, una caminata por la historia
Agregar variables en la pila que toma N bytes no aumenta (necesariamente) el tamaño del contenedor en N bytes. De hecho, agregará unos pocos bytes la mayor parte del tiempo.
Comencemos con un ejemplo de cómo agregar 1.000 caracteres a su código aumentará el tamaño del contenedor de forma lineal.
Si el 1k es una cadena, de mil caracteres, que se declara como tal
const char *c_string = "Here goes a thousand chars...999";//implicit /0 at end
y luego vim your_compiled_bin
, en realidad podrías ver esa cadena en el contenedor en alguna parte. En ese caso, sí: el ejecutable será 1 k más grande, porque contiene la cadena completa.
Sin embargo, si asigna una matriz de int
s, char
s o long
s en la pila y la asigna en un bucle, algo en esta línea
int big_arr[1000];
for (int i=0;i<1000;++i) big_arr[i] = some_computation_func(i);
luego, no: no aumentará el bin ... por 1000*sizeof(int)
La asignación en tiempo de compilación significa lo que ahora entiende que significa (según sus comentarios): el contenedor compilado contiene información que el sistema necesita para saber cuánta memoria necesitará la función / bloque cuando se ejecute, junto con información sobre el tamaño de pila que requiere su aplicación. Eso es lo que el sistema asignará cuando ejecute tu contenedor, y tu programa se convertirá en un proceso (bueno, la ejecución de tu contenedor es el proceso que ... bueno, obtienes lo que estoy diciendo).
Por supuesto, no estoy pintando la imagen completa aquí: el contenedor contiene información sobre la cantidad de una pila que el contenedor realmente necesitará. Basado en esta información (entre otras cosas), el sistema reservará una porción de memoria, llamada la pila, sobre la que el programa obtiene una especie de reinado libre. El sistema asigna la memoria de pila cuando se inicia el proceso (el resultado de la bandeja que se está ejecutando). El proceso administra la memoria de la pila por usted. Cuando se invoca / se ejecuta una función o bucle (cualquier tipo de bloque), las variables locales a ese bloque se envían a la pila, y se eliminan (la memoria de la pila se "libera", por así decirlo) para ser utilizada por otros funciones / bloques. Así que declarar int some_array[100]
solo agregará unos pocos bytes de información adicional al contenedor, que le indica al sistema que la función X requerirá 100*sizeof(int)
+ espacio de almacenamiento adicional.
Creo que necesitas dar un paso atrás. Memoria asignada en tiempo de compilación ... ¿Qué puede significar eso? ¿Puede significar que la memoria en los chips que aún no se han fabricado, para las computadoras que aún no se han diseñado, de alguna manera se está reservando? No. No, viaje en el tiempo, no hay compiladores que puedan manipular el universo.
Por lo tanto, debe significar que el compilador genera instrucciones para asignar esa memoria de alguna manera en el tiempo de ejecución. Pero si lo miras desde el ángulo correcto, el compilador genera todas las instrucciones, entonces, ¿cuál puede ser la diferencia? La diferencia es que el compilador decide, y en tiempo de ejecución, su código no puede cambiar o modificar sus decisiones. Si decidió que necesitaba 50 bytes en tiempo de compilación, en el tiempo de ejecución, no puede hacer que decida asignar 60 - esa decisión ya se ha tomado.
El núcleo de su pregunta es: "¿Cómo se asigna la memoria" en un archivo compilado? ¿No se asigna memoria en la RAM con todas las funciones de gestión de la memoria virtual? ¿La asignación de memoria por definición no es un concepto de tiempo de ejecución?
Creo que el problema es que hay dos conceptos diferentes involucrados en la asignación de memoria. En su base, la asignación de memoria es el proceso por el cual decimos "este elemento de datos se almacena en este pedazo específico de memoria". En un sistema informático moderno, esto implica un proceso de dos pasos:
- Algún sistema se usa para decidir la dirección virtual en la que se almacenará el artículo
- La dirección virtual está asignada a una dirección física
El último proceso es puramente tiempo de ejecución, pero el primero se puede hacer en tiempo de compilación, si los datos tienen un tamaño conocido y se requiere un número fijo de ellos. Aquí está básicamente cómo funciona:
El compilador ve un archivo fuente que contiene una línea que se parece a esto:
int c;
Produce salida para el ensamblador que le indica que reserve memoria para la variable ''c''. Esto podría verse así:
global _c section .bss _c: resb 4
Cuando el ensamblador se ejecuta, mantiene un contador que rastrea las compensaciones de cada elemento desde el inicio de un ''segmento'' de memoria (o ''sección''). Esto es como las partes de una ''estructura'' muy grande que contiene todo en el archivo completo, no tiene memoria real asignada en este momento, y podría estar en cualquier parte. Observa en una tabla que
_c
tiene un desplazamiento particular (digamos 510 bytes desde el inicio del segmento) y luego incrementa su contador en 4, por lo que la siguiente variable será (por ejemplo) 514 bytes. Para cualquier código que necesite la dirección de_c
, solo pone 510 en el archivo de salida y agrega una nota que el resultado necesita la dirección del segmento que contiene_c
y que lo agrega más adelante.El vinculador toma todos los archivos de salida del ensamblador y los examina. Determina una dirección para cada segmento para que no se superpongan y agrega los desplazamientos necesarios para que las instrucciones sigan refiriéndose a los elementos de datos correctos. En el caso de memoria no inicializada como la ocupada por
c
(al ensamblador se le dijo que la memoria no se inicializaría por el hecho de que el compilador lo puso en el segmento ''.bss'', que es un nombre reservado para memoria no inicializada), incluye un campo de encabezado en su salida que le dice al sistema operativo cuánto necesita reservarse. Puede reubicarse (y generalmente lo está), pero generalmente está diseñado para cargarse más eficientemente en una dirección de memoria particular, y el sistema operativo intentará cargarlo en esta dirección. En este punto, tenemos una muy buena idea de cuál es la dirección virtual que será utilizada porc
.La dirección física en realidad no se determinará hasta que el programa se esté ejecutando. Sin embargo, desde el punto de vista del programador, la dirección física es realmente irrelevante; nunca sabremos qué es, porque el sistema operativo no suele molestarse en contárselo a nadie, puede cambiar con frecuencia (incluso mientras el programa se está ejecutando) y una El propósito principal del sistema operativo es abstraer esto de todos modos.
En muchas plataformas, todas las asignaciones globales o estáticas dentro de cada módulo serán consolidadas por el compilador en tres o menos asignaciones consolidadas (una para datos no inicializados (a menudo llamados "bss"), una para datos de escritura inicializados (a menudo llamados "datos") ), y uno para datos constantes ("const")), y todas las asignaciones globales o estáticas de cada tipo dentro de un programa serán consolidadas por el vinculador en un global para cada tipo. Por ejemplo, suponiendo que int
es cuatro bytes, un módulo tiene lo siguiente como sus únicas asignaciones estáticas:
int a;
const int b[6] = {1,2,3,4,5,6};
char c[200];
const int d = 23;
int e[4] = {1,2,3,4};
int f;
le diría al enlazador que necesitaba 208 bytes para bss, 16 bytes para "datos" y 28 bytes para "const". Además, cualquier referencia a una variable sería reemplazada por un selector de área y desplazamiento, por lo que a, b, c, d, y e, serían reemplazados por bss + 0, const + 0, bss + 4, const + 24, datos +0, o bss + 204, respectivamente.
Cuando se vincula un programa, todas las áreas bss de todos los módulos se concatenan juntas; Del mismo modo, los datos y las áreas const. Para cada módulo, la dirección de cualquier variable relativa a bss aumentará en función del tamaño de todas las áreas bss de los módulos anteriores (de nuevo, del mismo modo que con los datos y const). Por lo tanto, cuando se hace el enlazador, cualquier programa tendrá una asignación de bss, una asignación de datos y una asignación de const.
Cuando se carga un programa, una de las cuatro cosas generalmente ocurrirá dependiendo de la plataforma:
El ejecutable indicará cuántos bytes necesita para cada clase de datos y para el área de datos inicializada, donde se pueden encontrar los contenidos iniciales. También incluirá una lista de todas las instrucciones que usan una dirección relativa de bss, data o const. El sistema operativo o cargador asignará la cantidad de espacio apropiada para cada área y luego agregará la dirección de inicio de esa área a cada instrucción que lo necesite.
El sistema operativo asignará un trozo de memoria para contener los tres tipos de datos y le dará a la aplicación un puntero a ese trozo de memoria. Cualquier código que utilice datos estáticos o globales lo desreferenciará en relación con ese puntero (en muchos casos, el puntero se almacenará en un registro durante toda la vida de una aplicación).
El sistema operativo inicialmente no asignará ninguna memoria a la aplicación, excepto lo que contiene su código binario, pero lo primero que hará la aplicación será solicitar una asignación adecuada del sistema operativo, que guardará para siempre en un registro.
El sistema operativo inicialmente no asignará espacio para la aplicación, pero la aplicación solicitará una asignación adecuada al inicio (como se indicó anteriormente). La aplicación incluirá una lista de instrucciones con direcciones que deben actualizarse para reflejar dónde se asignó la memoria (como con el primer estilo), pero en lugar de tener la aplicación parcheada por el cargador del sistema operativo, la aplicación incluirá el código suficiente para parchearse .
Los cuatro enfoques tienen ventajas y desventajas. En todos los casos, sin embargo, el compilador consolidará un número arbitrario de variables estáticas en un número fijo fijo de solicitudes de memoria, y el enlazador consolidará todas esas en un número pequeño de asignaciones consolidadas. A pesar de que una aplicación tendrá que recibir un trozo de memoria del sistema operativo o cargador, es el compilador y el enlazador los responsables de asignar piezas individuales de esa gran porción a todas las variables individuales que lo necesitan.
La memoria asignada en tiempo de compilación significa que cuando carga el programa, una parte de la memoria se asignará inmediatamente y el tamaño y la posición (relativa) de esta asignación se determinarán en tiempo de compilación.
char a[32];
char b;
char c;
Esas 3 variables están "asignadas en tiempo de compilación", significa que el compilador calcula su tamaño (que es fijo) en tiempo de compilación. La variable a
será un desplazamiento en la memoria, digamos, apuntando a la dirección 0, b
apuntará a la dirección 33 y c
a 34 (suponiendo que no hay optimización de la alineación). Por lo tanto, asignar 1Kb de datos estáticos no aumentará el tamaño de su código , ya que simplemente cambiará una compensación dentro de él. El espacio real se asignará en el momento de la carga .
La asignación de memoria real siempre ocurre en tiempo de ejecución, porque el kernel necesita hacer un seguimiento de la misma y actualizar sus estructuras internas de datos (cuánta memoria se asigna para cada proceso, páginas, etc.). La diferencia es que el compilador ya conoce el tamaño de cada uno de los datos que va a usar y esto se asigna tan pronto como se ejecuta su programa.
Recuerde también que estamos hablando de direcciones relativas . La dirección real donde se ubicará la variable será diferente. En el momento de la carga, el kernel reservará algo de memoria para el proceso, digamos en la dirección x
, y todas las direcciones codificadas en el archivo ejecutable se incrementarán en x
bytes, de modo que la variable a
en el ejemplo estará en la dirección x
, b en la dirección x+33
y así sucesivamente.
La memoria asignada en tiempo de compilación significa que el compilador resuelve en tiempo de compilación en donde se asignarán ciertas cosas dentro del mapa de memoria de proceso.
Por ejemplo, considere una matriz global:
int array[100];
El compilador conoce en tiempo de compilación el tamaño de la matriz y el tamaño de una int
, por lo que conoce el tamaño completo de la matriz en tiempo de compilación. Además, una variable global tiene una duración de almacenamiento estático por defecto: se asigna en el área de memoria estática del espacio de memoria de proceso (sección .data / .bss). Dada esa información, el compilador decide durante la compilación en qué dirección de esa área de memoria estática estará la matriz .
Por supuesto, las direcciones de memoria son direcciones virtuales. El programa asume que tiene su propio espacio de memoria completo (De 0x00000000 a 0xFFFFFFFF, por ejemplo). Es por eso que el compilador podría hacer suposiciones como "Bueno, la matriz estará en la dirección 0x00A33211". En tiempo de ejecución, las direcciones MMU y OS traducen direcciones a direcciones reales / de hardware.
El valor del almacenamiento estático inicializado es un poco diferente. Por ejemplo:
int array[] = { 1 , 2 , 3 , 4 };
En nuestro primer ejemplo, el compilador solo decidió dónde se asignará la matriz, almacenando esa información en el ejecutable.
En el caso de cosas inicializadas por el valor, el compilador también inyecta el valor inicial de la matriz en el ejecutable y agrega un código que le dice al cargador de programas que después de la asignación de la matriz al inicio del programa, la matriz debe llenarse con estos valores.
Aquí hay dos ejemplos del ensamblado generado por el compilador (GCC4.8.1 con objetivo x86):
Código C ++:
int a[4];
int b[] = { 1 , 2 , 3 , 4 };
int main()
{}
Conjunto de salida:
a:
.zero 16
b:
.long 1
.long 2
.long 3
.long 4
main:
pushq %rbp
movq %rsp, %rbp
movl $0, %eax
popq %rbp
ret
Como puede ver, los valores se inyectan directamente en el conjunto. En la matriz a
, el compilador genera una inicialización cero de 16 bytes, porque el estándar dice que las cosas almacenadas estáticas deben inicializarse a cero por defecto:
8.5.9 (Inicializadores) [Nota]:
Cada objeto de la duración del almacenamiento estático se inicializa en cero al inicio del programa antes de que se lleve a cabo cualquier otra inicialización. En algunos casos, la inicialización adicional se realiza más tarde.
Siempre sugiero a las personas que desensamblen su código para ver qué hace realmente el compilador con el código C ++. Esto se aplica desde las clases de almacenamiento / duración (como esta pregunta) hasta las optimizaciones avanzadas del compilador. Puede indicarle a su compilador que genere el ensamblado, pero hay herramientas maravillosas para hacerlo en Internet de manera amigable. Mi favorito es GCC Explorer .
La memoria asignada en tiempo de compilación simplemente significa que no habrá más asignaciones en tiempo de ejecución, no llamadas a malloc, nuevo u otros métodos de asignación dinámica. Tendrá una cantidad fija de uso de memoria incluso si no necesita toda esa memoria todo el tiempo.
¿La asignación de memoria no es, por definición, un concepto de tiempo de ejecución?
La memoria no está en uso antes del tiempo de ejecución, pero inmediatamente antes de la ejecución, el sistema maneja su asignación.
Si hago una variable asignada estáticamente de 1 KB en mi código C / C ++, ¿aumentará eso el tamaño del ejecutable en la misma cantidad?
Simplemente declarar la estática no aumentará el tamaño de su ejecutable más de unos pocos bytes. Declararlo con un valor inicial que no sea cero lo hará (para mantener ese valor inicial). Más bien, el enlazador simplemente agrega esta cantidad de 1 KB al requisito de memoria que el cargador del sistema crea para usted inmediatamente antes de la ejecución.
La memoria se puede asignar de muchas maneras:
- en el montón de aplicaciones (todo el montón se asigna para su aplicación por el sistema operativo cuando se inicia el programa)
- en el montón del sistema operativo (para que pueda agarrar más y más)
- en el montón controlado por el recolector de basura (igual que ambos arriba)
- en la pila (para que puedas obtener un desbordamiento de la pila)
- reservado en el segmento de código / datos de su binario (ejecutable)
- en un lugar remoto (archivo, red, y recibe un identificador, no un puntero a esa memoria)
Ahora su pregunta es qué es "memoria asignada en tiempo de compilación". Definitivamente es solo un enunciado incorrectamente expresado, que se supone que se refiere a la asignación del segmento binario o la asignación de la pila, o en algunos casos incluso a una asignación de montón, pero en ese caso la asignación está oculto a los ojos del programador por llamada de constructor invisible. O probablemente la persona que dijo que solo quería decir que la memoria no está asignada en el montón, pero no sabía acerca de las asignaciones de la pila o del segmento. (O no quería entrar en ese tipo de detalles).
Pero en la mayoría de los casos, la persona solo quiere decir que la cantidad de memoria asignada se conoce en el momento de la compilación .
El tamaño binario solo cambiará cuando la memoria esté reservada en el código o segmento de datos de su aplicación.
Me gustaría explicar estos conceptos con la ayuda de algunos diagramas.
Es cierto que la memoria no se puede asignar en tiempo de compilación, seguro. Pero, entonces, ¿qué ocurre, de hecho, en el momento de la compilación?
Aquí viene la explicación. Digamos, por ejemplo, que un programa tiene cuatro variables x, y, z y k. Ahora, en tiempo de compilación, simplemente crea un mapa de memoria, donde se determina la ubicación de estas variables entre sí. Este diagrama lo ilustrará mejor.
Ahora imagine, ningún programa se está ejecutando en la memoria. Esto lo muestro por un gran rectángulo vacío.
A continuación, se ejecuta la primera instancia de este programa. Puedes visualizarlo de la siguiente manera. Este es el momento en que realmente se asigna la memoria.
Cuando se ejecuta la segunda instancia de este programa, la memoria se vería de la siguiente manera.
Y el tercero ...
Y así sucesivamente.
Espero que esta visualización explique bien este concepto.
Si aprende la programación de ensamblaje, verá que debe crear segmentos para los datos, la pila y el código, etc. El segmento de datos es donde viven sus cadenas y números. El segmento de código es donde vive tu código. Estos segmentos están integrados en el programa ejecutable. Por supuesto, el tamaño de la pila también es importante ... ¡no querría un desbordamiento de pila !
Entonces, si su segmento de datos es de 500 bytes, su programa tiene un área de 500 bytes. Si cambia el segmento de datos a 1500 bytes, el tamaño del programa será 1000 bytes mayor. Los datos se ensamblan en el programa real.
Esto es lo que sucede cuando compilas idiomas de nivel superior. El área de datos real se asigna cuando se compila en un programa ejecutable, lo que aumenta el tamaño del programa. El programa también puede solicitar memoria sobre la marcha, y esta es memoria dinámica. Puede solicitar memoria de la RAM y la CPU se la dará, puede soltarla y su recolector de basura la devolverá a la CPU. Incluso puede ser cambiado a un disco duro, si es necesario, por un buen administrador de memoria. Estas características son las que le proporcionan los lenguajes de alto nivel.
Tienes razón. La memoria está realmente asignada (paginada) en el momento de la carga, es decir, cuando el archivo ejecutable se lleva a la memoria (virtual). La memoria también se puede inicializar en ese momento. El compilador solo crea un mapa de memoria. [Por cierto, los espacios de pila y montón también se asignan en el momento de carga!]
Un ejecutable describe qué espacio asignar para las variables estáticas. Esta asignación la realiza el sistema cuando ejecuta el ejecutable. Entonces su variable estática de 1kB no aumentará el tamaño del ejecutable con 1kB:
static char[1024];
A menos que, por supuesto, especifique un inicializador:
static char[1024] = { 1, 2, 3, 4, ... };
Por lo tanto, además del "lenguaje de máquina" (es decir, instrucciones de CPU), un ejecutable contiene una descripción del diseño de memoria requerido.