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.
- ¿Cuál es la diferencia entre los formatos de video: 2160p a 30 fps y DCI 2160p a 24 fps?
- ¿Es el aprendizaje profundo la mejor forma de aprendizaje automático?
- ¿Por qué siento que los informáticos no reciben respeto?
- ¿Qué significa volátil en términos informáticos?
- ¿Cómo cambiaría el mundo digital si pudieras comprimir cualquier archivo al 0.001% de su tamaño original para archivos de al menos 1 KB de tamaño?
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á).