¿Cómo manejan las computadoras los programas que no se ajustan a la CPU?

Por lo tanto, hay varias formas posibles de hacerlo. Una forma es con memoria virtual, que es un tema completamente diferente. Con VM, un programa puede pretender que todo está cargado y en direcciones fijas, y simplemente cargar los fragmentos (o páginas) necesarios a pedido. Otros han abordado esto, y es un tema enorme y fascinante.

La otra forma en que esto se puede hacer es usando el código de posición independiente. Las bibliotecas compartidas modernas usan esta técnica, y también pueden usarla los programas. Para explorar esto, voy a retroceder (¡hasta los 80!) Para hablar sobre una implementación en particular.

En los primeros días de la informática personal, los programas tenían que caber todos en la memoria a la vez. Esto, por supuesto, limita el tamaño del programa a lo que podría caber en la memoria disponible y aún deja espacio para los datos de trabajo. En el lado positivo, las cosas fueron un poco más fáciles para los creadores de compiladores; puede garantizar que la función DoSomething siempre estuvo en una determinada dirección, digamos 0x0100 (o 256 en decimal). No importa de dónde lo llame, llamar a DoSomething siempre significó saltar a la dirección 0x0100.

Para abordar esto, diferentes sistemas operativos utilizaron diferentes esquemas; Estoy más familiarizado con los Macintosh de esa época; para Mac, el código podría dividirse en diferentes “segmentos”. Cuando se inicia un programa, el sistema operativo mantendría una lista de los segmentos en la memoria, pero solo cargaría uno de ellos (CÓDIGO segmento 0). Las funciones dentro de un segmento se llamarían entre sí con direccionamiento relativo. Es decir, en lugar de decir “saltar a la instrucción en 0x0100”, la instrucción diría “saltar 100 instrucciones desde mi ubicación actual”. Esto significaba que el segmento podía ubicarse en cualquier lugar de la memoria una vez que se llamaba, ya que ninguna de las llamadas dependía de la dirección absoluta.

Sin embargo, las CPU generalmente tienen limitaciones sobre cuán grandes pueden ser los saltos en las llamadas relativas; Para el Motorola 68000 en el corazón de la Mac, la distancia de salto tenía que caber en 16 bits (IIRC), lo que significaba que solo podía saltar hasta 32768 bytes en cualquier dirección, lo que limitaba el tamaño de los segmentos de código a 32 KB. (Aunque técnicamente sería posible sortear esta limitación mediante un posicionamiento inteligente de las funciones o utilizando cálculos de compensación más costosos, el sistema operativo impuso el límite de 32 KB).

El trabajo de calcular las compensaciones correctas se dejó al vinculador; el programador simplemente llamaría a DoSomething desde cualquier parte del código. El compilador convertiría el programa en código de máquina, codificando las instrucciones para saltos relativos, pero dejaría marcadores de posición para todas las distancias de salto. El enlazador vería este código y diría “Bueno, DoSomething inicia 256 (0x100) bytes en este segmento de código (llamado” desplazamiento “), por lo que si hay una llamada a DoSomething en el desplazamiento 0x250, necesito reemplazarlo con un pariente salto de negativo 0x150.

Si quisieras llamar a una función fuera del segmento de código, las cosas se volverían un poco más complicadas; primero, el programador era realmente responsable de decidir qué funciones entraban en qué segmento de código. Cómo se hizo esto dependía del entorno de programación; con mayor frecuencia, simplemente especificaría que todas las funciones en un conjunto de archivos entraron en el segmento de código 0, todos los archivos en otro conjunto fueron al segmento de código 1, etc. La función especial “main”, que es la primera función llamada en un programa fue al segmento 0.

Durante la compilación, el compilador crearía una tabla de despacho que se mantendría en la memoria mientras dure el programa. Para cada segmento, la tabla rastrearía si el segmento se cargó, y si fuera así, la dirección del inicio del segmento.

Suponga que DoSomething estaba en el desplazamiento 0x0100 en el segmento 10. Cada vez que se llamaba a la función DoSomething desde un segmento diferente, se ejecutaba el siguiente código:

  • Busque el segmento 10 en la tabla de despacho. Si aún no está cargado, asigne un poco de memoria y almacene la dirección en la tabla de despacho.
  • Agregue el desplazamiento a DoSomething a la dirección del segmento 10 (nuevamente, el enlace se completa con el desplazamiento después de que se realiza toda la compilación). Esta será la dirección absoluta de DoSomething en la memoria, por lo que ahora puede saltar a ella.

Durante la ejecución del programa, si el programa se estaba quedando sin memoria, podría eliminar todos los segmentos de código que no se estaban ejecutando activamente. Simplemente eliminaría la memoria y marcaría el segmento como descargado en la tabla de despacho.

Este código adicional fue insertado por el compilador; para el programador, las llamadas a funciones en segmento y en segmento cruzado parecían idénticas; su única responsabilidad era especificar los límites entre segmentos. Por supuesto, las llamadas de segmentos cruzados fueron mucho menos eficientes, por lo que querrá hacer que todas las funciones interdependientes residan en el mismo segmento.

Una ventaja adicional era que se podían cargar varios programas de computadora a la vez. Ningún código dependía de ninguna dirección absoluta, por lo que podría tener docenas de programas cargados en la memoria sin interferir entre sí. Debo señalar que no es así como los sistemas operativos modernos logran la multitarea; en su lugar usan memoria virtual.

Las bibliotecas compartidas (o para Windows, DLL) son una versión moderna de esto. En estos, ni siquiera tiene la posición relativa de las funciones individuales en el momento del enlace; dentro de la biblioteca compartida, generalmente hay otra tabla para buscar las funciones individuales, y el sistema operativo proporciona código para buscar funciones y traducir su dirección en tiempo de ejecución.

Como dije, con la memoria virtual, ya no es necesario que los programas individuales estén hechos de código independiente de la posición, y en gran medida los programas tienen un diseño de memoria estática. Sin embargo, todavía hay ventajas; por ejemplo, aleatorizando ubicaciones de memoria, puede proporcionar un poco más de seguridad

Para explicar: en un ejecutable vinculado estáticamente (no independiente de la posición), el diseño en memoria está predefinido. En particular, las bibliotecas ampliamente utilizadas (como libc en sistemas UNIX y Linux) generalmente se encuentran en direcciones comunes, por lo que puede asumir la ubicación de funciones particularmente útiles (o destructivas) como la llamada al “sistema” (que le permite ejecutar shell arbitrario código) o funciones de eliminación de archivos

Ahora la memoria de código generalmente está protegida, lo que significa que los atacantes no pueden reescribir el código ejecutable real. Pero en un programa inseguro, pueden sobrescribir las direcciones de retorno que residen en la pila, ya que son datos. Si pueden manipular la pila para que “regrese” a “sistema ()”, entonces pueden tomar el control completo del proceso. (Consulte el ataque de retorno a la biblioteca: https://www.exploit-db.com/docs/…)

Con el código de posición independiente, el sistema operativo puede poner el inicio de la memoria de código en una dirección aleatoria, lo que significa que los atacantes ya no pueden confiar en que una función particular se encuentre en una ubicación particular. Si intentan “regresar” a libc, lo más probable es que sea un regreso a una dirección no válida, por lo que no pueden obtener el control (sin embargo, el programa aún se bloqueará).

En realidad, prácticamente ningún programa cabe en la CPU.

En un nivel altamente teórico (pasando por alto muchos detalles de implementación y optimizaciones), una CPU es un dispositivo que lee una secuencia de instrucciones de la RAM, una a la vez, y las ejecuta.

Una CPU solo tiene registros para el almacenamiento. Eso es aproximadamente una docena o dos docenas de “palabras” (por ejemplo, en una CPU de 64 bits, una palabra sería un campo de 64 bits) que la CPU puede abordar directamente. Uno de esos registros se llama puntero de instrucción. El puntero de instrucciones (IP) le dice a la CPU dónde está la siguiente instrucción en la RAM. Vamos a nombrar numéricamente los otros registros (registros de uso general) de nuestra CPU hipotética: R1, R2, R3 … etc.

En la ejecución normal, en cada ciclo de instrucción, la CPU buscará la siguiente instrucción de la RAM (en la dirección almacenada en IP) y la ejecutará. Una instrucción podría ser algo así como “almacenar 5 en R3”, “copiar R3 en R5”, “agregar R4 y R6 y almacenar el resultado en R8”. De vez en cuando se encontrará con una instrucción como “saltar a la dirección xxx”. Esta sería una llamada de función, por ejemplo, y en realidad es solo una escritura en IP.

PD. Sí, esta es una explicación muy simplificada, y se omiten muchos detalles de implementación y optimizaciones (cachés, canalización, cambio de nombre de registro, etc., etc.).

El procesador en sí solo tiene unos pocos registros para trabajar. El compilador genera instrucciones que le dicen al procesador que cargue datos en estos registros desde la RAM, realice operaciones elementales y luego vuelva a escribir el resultado en la RAM.

Creo que puede estar confundiendo la memoria caché. Hay una jerarquía de memoria en la que hay algo de memoria caché en el propio procesador, pero aún necesita cargar datos desde aquí en sus registros. El proceso es el mismo si los datos provienen de la memoria caché o de la memoria.

En respuesta a la pieza entre paréntesis de su pregunta, hay una pieza de hardware llamada unidad de administración de memoria que ayuda al sistema operativo a realizar traducciones de memoria virtual, pero no creo que esto sea relevante para su confusión.

El programa completo nunca se carga en la CPU (voy a ignorar el almacenamiento en caché aquí porque, aunque la memoria caché está en la CPU, no se trata por separado desde la memoria principal desde la perspectiva del programador). La CPU solo tiene un número muy limitado de áreas de almacenamiento llamadas registros. Tanto las instrucciones como los datos se cargan en estos registros según sea necesario. Un registro especial llamado puntero de instrucción (IP) contiene la dirección de memoria de la instrucción actual. Una vez que se ejecuta esa instrucción, la IP se incrementa, a menos que la última instrucción haya provocado un salto, en cuyo caso el valor almacenado en IP se cambiará a la dirección de destino del salto. La CPU está realmente “en el momento” en el sentido de que solo necesita conocer una instrucción, junto con sus operandos, en cualquier momento.

Los programas son rutinariamente demasiado grandes para caber en la memoria disponible de una computadora.

Es por eso que los arquitectos informáticos inventaron la paginación de memoria.

En los sistemas operativos de computadora, la paginación es un esquema de administración de memoria mediante el cual una computadora almacena y recupera las instrucciones del programa del almacenamiento secundario, según sea necesario durante la ejecución del programa de computadora.

Los programas de computadora se dividen en páginas. La CPU trabaja en las instrucciones de la página hasta que llega al final de la página o recibe una instrucción que se encuentra en otra página. Cuando esto sucede, el sistema primero ve si la página de destino ya existe en la memoria y, si es así, comienza la ejecución de las instrucciones en esa página. Si la página no está en la memoria, el sistema realiza un error de página y recupera la página del disco. Una vez que la página se guarda en la memoria, puede reiniciar la ejecución del programa.

La memoria de la computadora contiene páginas de muchos programas y está diseñada para ejecutar varios programas al mismo tiempo. Una falla de página, de manera óptima, no generará mucho tiempo perdido, porque el programa funcionará en otras páginas del programa que están en la memoria mientras faltan cargas de página.

La jerga que está buscando es fallas de página. Error de página – Wikipedia

Lo que la computadora hace específicamente sobre ellos depende del sistema operativo. El sistema operativo maneja la interrupción. El procesador interrumpe la ejecución normal del programa para dar el control al sistema operativo para que se encargue de agarrar la memoria que se necesita y colocarla donde el programa cree que pertenece. Cuando está hecho, el sistema operativo (que también es solo un programa, pero uno diferente) configura las cosas para que el procesador pueda volver a comenzar donde lo dejó.