Programación del x86 en modo protegido

 

La MMU de Intel  386 introduce un nuevo modo de direccionamiento además del modo real, denominado modo protegido. Este modo ofrece una alta flexibilidad haciendo posible espacios de direcciones planos de hasta 4GB por tarea (no segmentos), y hasta 64TB de memoria virtual. Este modo además añade alguna protección al objeto de soportar software que la necesita como sistemas operativos tipo Unix, o Windows NT. A lo largo de este documento vamos a cubrir estos conceptos.

            En modo protegido, los registros de segmentos están indexados en tablas especiales, todas inicializadas y mantenidas por el sistema operativo, pero interpretadas por la CPU. Existen tres tipos de tablas, todas localizadas en RAM o ROM:

  1. La Tabla de Descriptores Globales (GDT), única y siempre accesible
  2. La Tabla de Descriptores Locales (LDT), normalmente una por tarea. Puede haber varias en el sistema pero solo una esta activa en un momento dado.
  3. La Tabla de Descriptores deInterrupción (IDT) utilizada cuando se produce una interrupción.

Cada tabla contiene un número variable de descriptores y una estructura de 8-bytes que describe una región de memoria con los siguientes atributos (ver Figura):

Cuadro de texto:

 

 

 

 

 


-          Dirección base en memoria (32 bits)

-          El límite (20 bits, expresando en unidades de 4K o 1-byte.

-          Bits de control: el bit de granularidad (unidad del límite), el bit de presencia, y dos bits de protección.

-          El tipo de descriptor, uno de los 16 tipos soportados, entre ellos: segmento de código solo lectura ejecutable, segmento de datos, segmento de pila, llamada, trampa o puerta de interrupción, segmento de estado de tarea, etc.

 

Los registros de segmentos son selectores (índices) en las tablas GDT o LDT. Un selector (como un registro de segmento) contiene un índice de 13 bits, un indicador de tabla de 1 bit, y dos bits de protección. Ver Figura:

 

Cuadro de texto:  

 

 


 

 

Una dirección lógica, como la utilizada por un programa, es a pesar de eso una combinación de un segmento y un registro general, o más precisamente, un selector de 16 bits y un desplazamiento de 16 ó 32 bits. El selector identifica un descriptor, que a su vez suministra una dirección base de 32 bits, a la que se le añade el desplazamiento, formando una dirección final lineal de 32 bits, como se ve en la Figura. Este direccionamiento de 32 bits soporta hasta 4GB (232) de memoria.

 

Cuadro de texto:  

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

Todos los accesos a memoria dentro de un segmento pueden realizarse solo con el desplazamiento, simplificando la codificación de programas. Este cálculo de direcciones suministra muchas ventajas:

-          Como los registros segmentos cubren hasta 4 GB individualmente, no deben esta recargándose constantemente, incluso con enormes estructuras de datos, reduciendo la complejidad e incrementando la velocidad.

-          Los desplazamientos siempre comienzan en 0, independientemente de la ubicación del segmento en memoria física, haciendo que sea más fácil la depuración- la dirección (por ejemplo, desplazamiento) nunca cambia. Los segmentos se pueden mover en memoria física sin afectar a la aplicación que los utiliza.

-          Un desplazamiento debe estar dentro del límite de segmento; si no es así, se produce una excepción y el sistema operativo para a la aplicación afectada. Esto evita accesos incorrectos a memoria.

-          Los segmentos estan protegidos contra accesos indeseados, gracias a sus descriptores. Por ejemplo, una aplicación no puede escribir en el segmento de código, que es de solo-lectura.

-          Este modo de direccionamiento suministra un espacio virtual de direcciones colosal. Dado que un índice de selector tiene 13 bits, las tablas GDT y LDT estan limitadas a 8192 descriptores (213). Un único descriptor puede alcanzar 4GB (con una dirección base de 0, y límite de 1MB –FFFFFh), y una granularidad de 4KB (4 K*1MB= 4GB). Considerando que la GDT y la LDT junta tienen 16384 descriptores, el espacio virtual total es de 64 TB (16K*4GB). Aunque esto es astronómico, debemos darnos cuenta que la segmentación siempre produce una dirección lineal de 32 bits, limitando es espacio de direcciones físicas a 4GB.

-          Un sistema operativo puede usar pocos o muchos segmentos. La figura ilustra: (a) una aplicación reside junto al sistema operativo en un único segmento cubriendo 4GB; (b) una aplicación puede poseer sus propios segmentos, distintos de los segmentos del sistema operativo. Uno u otro enfoque dependerá de las restricciones del sistema.

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Activando el modo protegido

 

Vamos a ver un ejemplo de cómo activar el modo protegido. Este ejemplo se inicia en modo real y conmuta a modo protegido al objeto de ejecutar código de 32 bits en un modelo de memoria plano. Ver Listado 1 (incluir algunos comentarios sobre el ensamblado y enlazado?).

            El camino más corto para ejecutar código de 32 bits es cargar la GDT, activa el modo protegido, y saltar a un segmento de 32 bits. Si bien, este orden no es estricto. Por ejemplo, podemos activar primero el modo protegido, cargar la GDT, y hacer el salto.

            La GDT en el listado anterior esta previamente construida (líneas 97-121). Incluso si pensamos añadir dinámicamente descriptores más tarde, podemos tener en ella inicialmente descriptores estáticos para el inicio. En el ejemplo, la GDT ocupa 24 bytes y contiene tres entradas:

-          La entrada 0 es nula. Esta entrada no puede ser referenciada; los registros de segmento pueden inicializarse a 0 (por tanto, apuntar a ella) pero su uso produce una excepción. El objetivo de esta característica es identificar referencias de punteros far NULL. No obstante, puede contener algunos datos dado que su descriptor nunca es usado por la CPU. Por ejemplo, simplemente contiene ceros.

-          Entrada 1 (selector 08h) se utiliza para el código del kernel, con base 0, límite FFFFFh, con granularidad activada (4K), y el tipo ejecutable, solo-lectura.

-          Entrada 2 (selector 10h) utilizada por los datos del kernel, también como base 0, límite FFFFFh, con el bit granularidad activo y marcada como segmento de escritura. Este segmento de datos se superpone con el segmento de código. Juntos suministran un espacio de direcciones plano.

 

La GDT se carga inicializando el registro GDTR con la dirección base de GDT y su tamaño, ambos almacenados en una estructura de datos de 6 bytes (línea 61). El registro GDTR se carga normalmente ejecutando una instrucción de 16 bits lgdt fword ptr address.  Pero esta instrucción no puede escribirse como tal pues su “opcodes” resultante de 32 bits no funciona en modo real. Para que las cosas vayan peor, las direcciones se deben expresar en valores de 32 bits (una restricción del enlazador), en lugar de los 16 bits que se esperan en modo real. Para solventar este problema, se utiliza una instrucción de 16 bits mov ebx,address y lgdt fword ptr [bx] codificadas en la macro LGDT32 (líneas 17-25)., y llamada desde la línea 47. La dirección se especifica como un valor de 32 bits., aunque solo se utiliza la porción de los 16 bits de menor peso. Pero sobre todo, esta carga adecuadamente el registro GDT mientras la CPU esta ejecutando en modo real.

            Con el registro GDT activo, el modo protegido se activa ajustando el bit #0 del registro CR0 (líneas 49-51). CR0 es un registro de control que controla la segmentación y paginación, entre otras cosas. CR0 solo puede leerse o escribirse utilizando registros como operandos. El ejemplo, utiliza el registro AX. Tan pronto como CR0 esta activo, la CPU esta en modo protegido y comienza a ejecutar código de 16 bits, pero en modo protegido (los registros de segmento quedan indexados en una tabla).

            El contenido de todos los registros de segmento es desconocido en este punto. Sin embargo, esta garantizado que pueden seguir siendo utilizados para acceder a instrucciones o datos posteriores. E  inmediatamente después de que este activo el modo protegido, la cola de instrucciones de la CPU debe limpiarse puesto que contiene instrucciones traidas (pre-fetched) con antelación en modo real, que no son ya válidas en modo protegido. La cola puede limpiarse saltando a la siguiente instrucción (línea 52).

            Lo último a hacer en 16 bits es conmutar a 32. Esto se consigue cargando el registro de segmento de código (CS) con un relector que referencie un descriptor de código ejecutable de 32 bits. La segunda entrada en la GDT, es tal descriptor. En la línea 57, la macro de salto FJMP32 (líneas 27-32) se ejecuta con el selector 08h y el desplazamiento Start32. Dado que el descriptor contiene 0 como dirección base, el desplazamiento es sencillamente la localización física de la primera instrucción a ejecutar en modo 32 bits. En este caso, esta instrucción esta en Start32 (línea 65).

            Una vez en modo 32 bits, lo mejor es inicializar los registros de datos (DS, ES, FS, y GS) y el registro de pila (SS) (líneas 73-78). Observar que existen segmentos de pila de 16 y 32 bits: el tamaño determina cuantos bytes (dos o cuatro) salva un push normal en la pila. El ejemplo utiliza la tercera GDT como un combinado de datos y segmento de pila de 32 bits (selector 10h). Finalmente, ESP se fija a un valor de cima arbitrario.

            Un sistema completo seguiría con la inicialización, tal como cargando e inicializando la IDT, ajustar el hardware, etc. Pero estas cuestiones estan fuera de la cabida de este tema.

 

referencia un registro de segmento, la CPU accede al descriptor relacionado y analiza sus bits de control. Si la operación no concuerda con esos bits, el procesador genera una excepción, que normalmente es manejada por el sistema operativo.

 

Listado 1:- Conmutar de modo real (16 bits) a modo protegido (32-bits):

Cuadro de texto: Conmutar de modo real 16-bit a modo protegido 32-bit.
1. ; ProtMode.asm
2. ; Copyright (C) 1998, Jean L. Gareau
3. ;
4. ; El programa muestra cómo conmutar desde modo real 16-bit en
5. ; modo protegido 32-bit. Algunas instruccuiones de modo real se implementan con macros 
6. ; al objeto de poder utilizarlas como operandos de 32-bit.
7. ;
8. ; Este programa ha sido ensamblador con MASM 6.11:
9. ;               C:\>ML ProtMode32.asm
10.
11.                                                        .386P                                ; Usa instrucciones privilegiadas 386+
12.
13. ;------------------------------------------------------------------------------------------------------;
14. ; Macros (para usaar instrucciones de 32-bit mientras estamos en modo real);
15. ;------------------------------------------------------------------------------------------------------;
16.
17. LGDT32                      MACRO        Addr                                 ; 32-bit LGDT Macro en 16-bit
18.                                                        DB                66h              ; 32-bit sustitución operando
19.                                                        DB                8Dh              ; lea (e)bx,Addr
20.                                                        DB                1Eh
21.                                                        DD                Addr 
22.                                                        DB                0Fh              ; lgdt fword ptr [bx]
23.                                                        DB                01h
24.                                                        DB                17h
25. ENDM
26.
27. FJMP32                      MACRO        Selector,Offset                ; 32-bit Far Jump Macro in 16-bit
28.                                                        DB                66h              ; 32-bit sustitución operando
29.                                                        DB                0EAh            ; far jump
30.                                                        DD                Offset          ; 32-bit offset
31.                                                        DW               Selector       ; 16-bit selector
32. ENDM
33.
34.                                                        PUBLIC         _EntryPoint  ; El enlazador lo necesita
35.
36. _TEXT                        SEGMENT PARA USE32 PUBLIC 'CODE'
37.                                                        ASSUME      CS:_TEXT
38.
39.                                                        ORG                                 5000h          ; => Depende ubicación del código
40.
41. ;-----------------------------------------------------------------------------;
42. ; Punto de entrada. La CPU ejecuta modo real 16-bit.        ;
43. ;-----------------------------------------------------------------------------;
44.
45. _EntryPoint:
46.
47.                                                        LGDT32       fword ptr GdtDesc          ; Carga descriptor GDT
48.
49.                                                        mov                                  eax,cr0        ; Toma el registro de control 0
50..                                                       or                                     ax,1             ; Ajusta bit PE (bit #0) en (e)ax
51.                                                        mov                                  cr0,eax        ; Activa modo protegido!
52.                                                        jmp                                   $+2              ; Limpia cola instrucciones.
53.
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Cuadro de texto: 54. ; La CPU ejecuta ahora modo protegido 16-bit. Realiza un far jump para
55. ; cargar CS con un selector a un descriptor de código ejecutable de 32-bit.
56.
57.                                                        FJMP32        08h,Start32                      ; Salta a Start32 (abajo)
58.
59. ; Este punto nunca se alcanza. Siguen datos.
60.
61. GdtDesc:                                                                                                     ; descriptor GDT
62.                                                        dw               GDT_SIZE - 1                   ; GDT limite
63.                                                        dd                Gdt                                   ; GDT dirección base (abajo)
64.
65. Start32:
66.
67. ;-----------------------------------------------------------------------------;
68. ; La CPU ejecuta ahora modo protegido 32-bit.                  ;
69. ;-----------------------------------------------------------------------------;
70.
71. ; Inicializa todos los registros del segmento a 10h (entrada #2 en la GDT)
72.
73.                                                        mov              ax,10h                              ; entrada #2 en GDT
74.                                                        mov              ds,ax                                ; ds = 10h
75.                                                        mov              es,ax                                ; es = 10h
76.                                                        mov              fs,ax                                ; fs = 10h
77.                                                        mov              gs,ax                                ; gs = 10h
78.                                                        mov              ss,ax                                ; ss = 10h
79.               
80. ; Establece la cima de la pila para permitir las operaciones de pila.
81.  
82.                                                        mov              esp,8000h                       ; cima de la pila arbitraria
8
84. ; Aqui vienen otras operaciones de inicialización.
85. ;                                  ...
86.
87. ; Estep unto nunca es alcanzado . Siguen datos.
88.
89. ;-----------------------------------------------------------------------------;
90. ; GDT                                                                                                                                                                                                                                                                                                                                                             ;
91. ;-----------------------------------------------------------------------------;
92.
93. ; Global Descriptor Table (GDT) (acceso rápido si esta alineada a 4).
94.
95.                                                        ALIGN          4
96.
97. Gdt:
98.
99. ; GDT[0]: entrada nula, nunca utilizada.
100.    
101.                                                      dd                0
102.                                                      dd                0
103.             
104. ; GDT[1]: Código solo lectura ejecutable, dirección base 0, limite de FFFFFh, 
105. ;           activo bit granularidad (G)  hace que el límite sea 4GB)
106.  
107.                                                      dw               0FFFFh                             ; Limite[15..0]
108.                                                      dw               0000h                               ; Base[15..0]
109.                                                      db                00h                                   ; Base[23..16]
110.                                                      db                10011010b                       ; P(1) DPL(00) S(1) 1 C(0) R(1) A(0)
111.                                                      db                11001111b                       ; G(1) D(1) 0 0 Limite[19..16]
112.                                                      db                00h                                   ; Base[31..24]
113.                                  
114. ; GDT[2]: Segmento datos escritura,  cubre el espacio de direcciones salvado en GDT[1].
115.  
116.                                                      dw               0FFFFh                             ; Limite[15..0]
117.                                                      dw               0000h                               ; Base[15..0]
118.                                                      db                00h                                   ; Base[23..16]
119.                                                      db                10010010b                       ; P(1) DPL(00) S(1) 0 E(0) W(1) A(0)
120.                                                      db                11001111b                       ; G(1) B(1) 0 0 Limit[19..16]
121.                                                      db                00h                                   ; Base[31..24]
122.  
123. GDT_SIZE                EQU                                  $ - offset Gdt                   ; Tamaño, en bytes
124.
125. _TEXT  ENDS
126.                                  END
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Protección y segmentación

 

En el presente apartado, vamos a estudiar como se implementan la protección y la segmentación en el modo protegido de forma que permitan el diseño de sistemas robustos y fiables.

            La protección de la que hablamos aquí tiene tres pliegues:

-          evitar que una tarea ejecute instrucciones privilegiadas, tales como limpiar o ajustar el indicador de interrupciones.

-          Evitar que una tarea acceda a código o datos de otra tarea

-          Evitar que las tareas invoquen código privilegiado del kernel de forma desordenada o corrompan estructuras de datos del mismo.

 

Así que protección aquí no tiene que ver con autentificación, dado que este es un concepto implementado por el SO, no por la CPU.

            En el x86, la protección es una característica asociada con los segmentos, y automáticamente activada cuando la CPU se ejecuta en modo protegido. Cuando se referencia un registro de segmento, la CPU accede a su descriptor asociado y analiza sus bits de control. Si la operación no concuerda con esos bits, el procesador produce una excepción, que normalmente es atrapada y manejada por el SO. Ejemplos de esto pueden ser escribir en el segmento de código (es solo-lectura), saltar al segmento de datos (no es ejecutable), etc.

            Otra comprobación de protección afecta a la comprobación del desplazamiento en el cálculo de la dirección frente al límite del segmento. Si una operación intenta superar el límite se produce una excepción (el ejemplo más común es un puntero incorrecto o un salto inválido). La comprobación del límite es útil pues restringe a una tarea a su propio segmento.

            Cada tarea requiere normalmente dos descriptores, uno para código y otro para datos. El segmento de código es de solo-lectura y no puede usarse para modificar datos, por tanto necesitamos un segundo segmento para acceder a los datos. Normalmente, los descriptores no cubren el mismo espacio de direcciones (es decir, no se solapan) al objeto de mantener la protección inicial (no sobrescribir código, etc.). Los segmentos del modelo plano de memoria se solapan, pero este modelo es normalmente utilizado en conjunción con la paginación (que veremos después).

             El suministrar un espacio de direcciones distinto por tarea no es suficiente por si mismo para obtener protección, necesitamos además LDTs, segmentos de estado de tarea (TTSs), niveles de privilegio y descriptores de puertas. La razón de ello obedece a que las GDT al ser global permite teóricamente que una tarea cargue el descriptor de datos de otra tarea y altere los segmentos de esta.

            Las LDTs y las IDTs son construidas por el SO, no por las tareas. Una LDT dada contiene los descriptores del código y datos de la misma, y se construye cuando la tarea se carga en memoria. Como puede haber varias LDTs en el sistema, solo una esta activa en un momento dado, en concreto, la apuntada por el registro LDTR.

Una LDT viene descrita por un descriptor cuya dirección base es la dirección de la LDT en memoria, y su límite es el tamaño de la LDT. Un descriptor LDT se mantiene normalmente en la GDT (para que sea accesible siempre). Una LDT es referenciada (en lugar de la GDT) cuando se usa un selector con su segundo bit activo en una instrucción. La instrucción lldt (load local descriptor table) realiza esto y solo requiere un selector de la LDT activa.

            En un sistema donde cada tarea tiene su propia LDT, podemos mantener el selector en el bloque de control de la tarea (o estructura similar) para identificar esta LDT como la LDT actual cuando seleccionamos la tarea para ejecutarse. Tales sistemas suministran una buena protección dado que cada tarea ve la GDT y su LDT, pero nolas LDTs de otras tarea.

            Si un sistema suministra este tipo de aislamiento entre tareas, el SO debe suministrar primitivas para transferir información entre tareas (por ejemplo, para enviar y recibir mensajes).

 

Niveles de privilegio

 

A pasar de las LDT, una tarea puede aun referenciar cualquier descriptor de la GDT y alterar algunos datos. Para evitar que ocurra esto, se introducen los niveles de privilegio. La CPU de Intel suministra cuatro niveles: 0 (más privilegiado) a 3 (menos privilegiado). El nivel 0 permite la ejecución de instrucciones privilegiadas (tales como ajustar las interrupciones, acceder a los puertos de e/s, cargar la GDT, IDT o IDT, etc.), las cuales estan totalmente prohibidas a los otros niveles. El SO debe ejecutarse en nivel 0 para no tener restricciones, mientras que debemos decidir si los manejadores de dispositivos, extensiones del SO, y tareas de aplicaciones se ejecutan en los niveles 0, 1, 2, ó 3. Lo más común es usar los niveles 0 para el sistema operativo y 3 para las aplicaciones, ignorando los niveles intermedios.

            Un nivel de privilegio esta siempre asociado con el código de ejecución actual. Cuando entramos en modo protegido, el nivel de privilegio actual (CPL) es 0 (el mayor) dado que se espera que se ejecute el SO. Todos los descriptores contienen un descriptor de nivel de privilegio (DPL), que es un valor de 2 bits identificando el privilegio del segmento relacionado. DPL tiene un significado diferente dependiendo del tipo de segmento (código o datos):

ü      Cuando se carga el registro segmento de código (CS) con un selector de código válido (via jmp, call, o retorno de una función o interrupción), la CPU examina el descriptor y el DPL se convierte en el CPL, como muestra la figura. Por ejemplo, una vez que se completa la inicialización del SO (se ejecuta a CPL 0), se ejecuta la primera tarea cargando CS con el selector del descriptor de código de la tarea con un CPL 3; en consecuencia, la tarea comienza a ejecutarse con CPL 3. Pero hay un truco: cuando cargamos CS, el CPL nunca puede ser más privilegiado; debe permanecer en el mismo nivel o inferior. Así, una tarea con CPL 3 no puede cargar CS con un DPL 0, 1 ó 2 (si lo hiciese generaría una excepción).

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 


ü      Cuando se referencia un registro segmento de datos, el DPL del citado descriptor indica el mínimo CPL necesario para acceder a el (el CPL debe ser igual o superior que le DPL. Así, una tarea con CPL 2 no puede referenciar un descriptor de datos con DPL 0 ó 1; solo puede acceder a datos con descriptor con DPL 2 ó 3.

 

El SO debe ajustar cuidadosamente los DPLs de todos los descriptores GDT y LDT para evitar usos no autorizados por parte de las tareas de código y datos protegidos. Por ejemplo, todos los descriptores del SO se ajustan para que sus DPL sean 0, mientras que todos los descriptores de tareas se marcan con DPL igual a 3. En consecuencia, las tareas no pueden acceder directamente al código ni datos del SO.

 

Segmentos de estado de tareas

 

Antes de explora en detalle los niveles de privilegio, vamos a introducir los segmentos de estado de tarea (TSS).  Un TSS es un almacén para todos los registros de una tarea cuando esta no se esta ejecutando. Como las LDTs, solo un TSS esta activo en cada momento y es interpretado en algún instante por la CPU. También esta descriptor por un descriptor que indica la dirección base, el tamaño (que puede variar pues se pueden almacenar datos extras de cada uno de ellos), la protección, y el tipo (que en este caso es TSS). El registro TS contiene el selector del TSS activo.

            Tener un TSS por tarea en un sistema segmentado es algo común. En este caso, normalmente, los descriptores TSS se mantiene en la GDT, con un DPL 0 para evitar que una tarea (con CPL 1, 2 ó 3) pueda acceder a ellos. Un selector TSS de tarea puede tambien almacenarse en un bloque de control de tarea para referencias rápidas. El SO puede indicar que  TSS esta activo ejecutando la instrucción ltr (load task register).

            Los TSSs son especiales: un “far jmp” a un selector TSS (se ignora el desplazamiento) realiza un cambio de contexto desde la tarea actual a la tarea referenciada por el TSS seleccionado. Este cambio no solo salva y restaura los registros de las tareas, si no que además maneja los registros de segmentos, el selector LDT actual, el selector TSS actual, etc. todo con una única instrucción. Es aconsejable mantener todos los descriptores LDT y TSS en la GDT para asegurar su accesibilidad durante el cambio.

 

Mezclando niveles de privilegio

 

La máxima protección se obtiene mezclando niveles de privilegio. Afortunadamente, el x86 tiene bastante que ofrecer a la hora de mezclar niveles de privilegio.

            Si deseamos ejecutar un código privilegiado, tal como llamar a una función del SO a CPL 0 desde un CPL 3, podemos usar puertas de llamada (ver Figura abajo). Las puertas de llamada son un tipo especial de descriptor y puede residir en la GDT (haciéndolas compartibles entre todas las tareas) o en la LDT de una tarea (haciéndola privada de la tarea). Una puerta de llamada no es más que una llamada indirecta y controlada a una función más privilegiada, típicamente un servicio del SO. La puerta de llamada contiene un selector de código y la dirección de la función a llamar dentro de ese selector. El selector de código tiene usualmente un mayor nivel de privilegio, permitiendo que los servicios del sistema se ejecuten con los privilegios adecuados. Las puertas de llamada son inicializadas y mantenidas por el SO, pero son utilizadas por las tareas.

 

 

 

 

 

 

 

Cuadro de texto:  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


            Mediante un “far call” (una combinación de selector/desplazamiento) se accede a las puertas de llamada, si bien solo el selector tiene sentido (el desplazamiento se descarta). Las puertas de llamada pueden esconderse en un función normal (tale como open()), haciéndolas invisibles a los programadores de aplicaciones.

            La CPU asegura que la tarea actual tiene el privilegio suficiente para usar la puerta de llamada. Por ejemplo, si una puerta de llamada tiene DPL 2, solo las tareas que se ejecutan con CPL 0, 1 ó 2 pueden utilizarla. Pero es común ajustar todas las puertas de llamada a DPL 3, para hacerlas disponibles a todas las tareas. También, el DPL del segmento de código objetivo deber tener el mismo o mayor privilegio que el CPL. Por ejemplo, si una tarea ejecutándose con CPL 2 utiliza una puerta de llamada que referencia un segmento con CPL 3, se dispara una falta. Las puertas de llamada solo se utilizan para incrementar el nivel de privilegio, no para decrementarlo; si no fuese así, en el retorno habría un incremento de privilegio descontrolado (lo que sería desastroso si la tarea modifica la dirección de retorno). Por esta razón, se realizan varios controles cuando termina un servicio del sistema para asegurarnos de que el control se devuelve a un segmento de código de igual o menor privilegio.

            Observar que es posible una “far call” a un segmento menos privilegiado, en cuanto que sea un segmento conforme. Un segmento es conforme cuando esta activo un bit especial de control en su descriptor. Tal segmento acuerda con su llamador en que se ejecuta bajo el CPL del llamador. Por ejemplo, si la tarea actual se ejecuta a CPL 2, e invoca a una función en un segmento conforme con DPL 3, esta función también se ejecutará con CPL 2. Así, llamar a un segmento conforme no altera el CPL de la tarea llamadora. Los segmentos conformes son una forma útil de implementar bibliotecas de sistema invocables por cualquier tarea, sin importar su privilegio. Sin embargo, los segmentos conformes suelen ser raros, ya que bibliotecas (como la biblioteca C) se ligan a la aplicación en tiempo de enlace, no de ejecución.

            Pero una puerta a llamada no es suficiente para asegurar la ejecución con éxito. Se debe asegurar suficiente espacio en la pila para que el servicio del SO se ejecute. Como la tarea llamadora puede tener poco espacio de pila, la puerta de llamada realizará un cambio de pila si se incrementa el nivel de privilegio. El TSS es importante aquí, ya que además de los registros de tarea, guarda los punteros a las pilas para los niveles 0, 1, 2 y 3, todos inicializados por el SO. Un ejemplo de cómo funciona: una tarea con CPL 3 utiliza una puerta de llamada para ejecutar un servicio del sistema con DPL 0; se mira el TSS de la tarea actual para obtener el puntero a la pila de privilegio 0, y su valor se toma como la pila efectiva. El puntero (selector/desplazamiento) a la pila original de la tarea se empuja en la nueva pila para poder retornar (ver Figura ¿?). Además del cambio de pila, se pueden copiar hasta 32 parámetros (32 dobles palabras) desde la pila de la tarea a la nueva pila. El número de parámetros por puerta de llamada es fijo (las puertas de llamada no soportan un número variable de parámetros).

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 


            La alternativa a utilizar puertas de llamada es el uso de la interfaz de trampas, que consiste en invocar a las funciones del SO generando una interrupción software. Una interfaz trampa involucra una tabla de interrupciones (IDT). Una IDT puede contener tres tipos de descriptores:

üPuerta de interrupción – que se refiere a una función específica, normalmente en el kernel.

üPuerta trampa – que es similar a una puerta de llamada.

üPuerta de tarea – que apunta a un descriptor TSS

 

Al igual que las puertas de llamada, invocar a un método a través de la IDT es una forma de incrementar el CPL. Cada descriptor IDT contiene un DPL, normalmente ajustado a 0 por el SO. Tal DPL evita que tareas no privilegiadas disparen directamente la interrupción mediante la instrucción int. Observar que algunos descriptores IDT pueden tener un DPL menor haciéndolos invocables por las tareas para implementar llamadas al sistema. Estas interrupciones software o trampas pueden también esconderse dentro de una función de biblioteca.

            Cuando se produce una interrupción hardware o una interrupción software válida, se analiza el descriptor relacionado de la IDT y el CPL igualado al DPL del descriptor (ver Figura ¿?). Para una puerta de interrupción, la ejecución comienza en la dirección encontrada en el descriptor (típicamente un manejador de interrupciones del SO) con las interrupciones deshabilitadas; cuando este finaliza, se realizan comprobaciones extras de protección para asegurar el retorno correcto al llamador. Las puertas trampa son casi idénticas, ocurre el mismo procesamiento pero con las interrupciones habilitadas. Con una puerta de tarea, se produce un cambio de contexto. Observar que las puertas de tarea no son un medio conveniente para implementar multitarea, ya que el cambio de contexto ocurre bajo las condiciones del SO (expira el cuantum, llamadas al sistema que hacen que una tarea de mayor prioridad este preparada, etc.) más que cuando se producen interrupciones. Además, no todos los procesadores disponen de este mecanismo, por lo que los diseñadores de SOs prefieren hacer el cambio de contexto en software para así poder transportar el SO más fácilmente a otra plataforma (en cuanto a la eficiencia es similar en ambos casos). Sin embargo, estas pueden ser una forma útil de invocar a tareas especiales, como un depurador.

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


            La CPU invoca al manejador descrito por las puertas de interrupción o de trampas de una maneja similar a las puertas de llamada: si se incrementa el privilegio, se produce un cambio de pila utilizando el TSS para obtener el nuevo puntero a pila. Sin embargo, a diferencia de una puerta de llamada,  no se copian parámetros. Si se necesitan parámetros, estos deben pasarse a través de los registros o pueden recuperarse a través del puntero de pila de la tarea, que esta en la nueva pila.

            La elección entre puertas de llamada frente a puertas de interrupción o trampa para la ejecución de servicios del SO depende de cuantas llamadas al sistema tengamos y de si estas requieren o no argumentos.

Si tenemos muchas llamadas al sistema, las puertas de interrupción/trampas son mejores dado que ofrecen un único punto de entrada al kernel; sin embargo, se debe utilizar un registro para identificar el servicio invocado. Por otra parte, dado que las puertas de llamada referencian a una función, la existencia de muchas llamadas al sistema implica muchas puertas de llamada. Como los sistemas operativos generales tienen cientos de llamadas al sistema, las puertas de llamada serían complicadas de mantener. Es más, si se sitúan en la LDT, la cosa se complica más.

            Si la tarea pasa parámetros, las puertas de llamadas nos permiten transferirlos a una pila más privilegiada, a los cuales la llamada al sistema accede localmente (fácil). Vía la interfaz de interrupciones/trampas, deberemos tracear la pila llamadora (lo que es desesperante).

            Las puertas de llamada necesitan punteros “far”, mientras que las trampas o interrupciones son disparadas sencillamente mediante una instrucción (sin punteros ni registros de segmento) lo que es más rápido.

            Una precaución: como quiera que la CPU se ve involucrada en comprobaciones de privilegios, los ciclos de ejecución se incrementan drásticamente. Un ejemplo, sobre un 386 la instrucción más rápida, excluyendo cerrojos, necesita dos ciclos, pues bien:

ü      Las operaciones sobre descriptores, tales como lsl (load segment limit), lar (load access right bit), llevan más de diez ciclos. El acceso directo a las tablas que los contienen podría ser una forma más rápida de obtener la información.

ü      La carga de un segmento de registro se lleva al menos 18 ciclos, comparado con los 2 de un registro general. Este tiempo extra se debe a la validación extra del descriptor. Recordar que dentro de una tarea, debería cargar un registro de segmento sólo si el nuevo valor es diferente (la comparación de la propia instrucción solo toma 2 ciclos).

ü      Cargar la LDT actual (instrucción lldt) toma 20 ciclos.

ü      Cargar el registro de tarea (ajusta el TSS actual, instrucción ltr) se toma al menos 23 ciclos.

ü      Una puerta de llamada/interrupción/tarea hacia una descriptor de mayor privilegio toma un mínimo de 90 ciclos (y se incrementa con el número de parámetros para una puerta de llamada)

ü      Y el peor caso: un cambio de tarea mediante TSS se lleva más de 300 ciclos. Los cambios de tarea TSS son solo útiles si todos los registros, especialmente los registros de segmento, debe ser recargados con nuevos valores.

           

Ejemplos de implementación

 

Estas cuatro características (LDT, TSS, niveles de privilegio, y las diferentes puertas) pueden combinarse de varias formas para satisfacer necesidades específicas. Vamos a ver algunos ejemplos para implementar protección en el SO, desde el más simple al más seguro.

 

Caso 1:

 

El SO y una única tarea se ejecutan con privilegio 0 (ver Figura ¿?). Este caso es ideal para controladores de tiempo-real de 32 bits y es sencillo de implementar, tales como un analizador de mezcla de combustible-aire que necesita registros de 32 bits para realizar los cálculos con cierta precisión.

ü      El SO y la tarea forman una imagen combinada.

ü      Se necesitan dos entradas en la GDT (además de la primera entrada): un descriptor de código (cero a 4GB, DPL 0), un descriptor de datos (0 a 4 GB, DPL 0). Los registros de segmentos siempre referencias estos descriptores de código y datos.

ü      Las interrupciones hardware son implementadas vía puertas de interrupción, todas con DPL 0, que invocan a los manejadores de interrupciones.

ü      No se utilizan puertas de llamada ni de tarea; los servicios del sistema pueden invocarse directamente.

ü      No se necesita TSS dado que solo hay una tarea y no hay transición de privilegios.

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Caso 2:

 

El SO y varias tareas se ejecutan a nivel 0. Este modelo puede ser el mejor para un kernel de tiempo-real multitarea, como por ejemplo C/OS. Este caso es el ideal para un sistema de suspensión que necesita múltiples tareas controlando simultáneamente sistemas hidráulicos, fuerza de suspensión, recoger datos estadísticos, etc.

ü      Como todas las tareas tienen el mayor privilegio, pueden compartir un único segmento, por lo que no es necesario usar LDTs. Así, solo se requieren en la GDT un descriptor de código y uno de datos (CPL 0, 0 a 4GB).

ü      Las interrupciones se implementan mediante puertas de interrupción (DPL 0).

ü      NO son necesarios TSSs ya que no existe transición de privilegios y los registros de segmentos no cambian. La conmutación de tarea se realiza salvando los registros de la aplicación en la pila, cambiando de pila, y recuperando los registros de la nueva pila.

 

Caso 3:

 

El So se ejecuta a nivel 0 y muchas tareas a nivel 3 (Figura ¿?). Este modelo puede ser el mejor para una sistema sencilla que ejecuta tareas no confiables, como una Máquina Virtual Java empotrada que soporta applets Java desconocidas.

ü      Todos los descriptores en la GDT tienen DPL 0 para evitar que las tareas lo ejecuten directamente.

ü      La GDT tiene un descriptor de código y uno de datos (CPL 0, 0 a 4GB) para el uso exclusivo del kernel.

ü      Cada tarea se ejecuta en su propio espacio de direcciones, y necesita su LDT privada con dos entradas: una para el código y otra para los datos. Para un modelo plano de memoria, los segmentos de código y datos de una misma tarea se pueden solapar; para una mejor protección, pueden ser distintos. En este último caso, cada tarea debe construirse aparte (no enlazada con el kernel) utilizando un modelo de memoria pequeño, y que se carga para ejecutarlo (se necesita un cargador). Las tareas se ejecutan a nivel 3. Las LDTs evitan que las tareas se vean las unas a las otras.

ü      La IDT contiene descriptores que referencian los manejadores de interrupciones en el kernel, con DPL 0, para asegurar que el kernel siempre se ejecuta a nivel 0.

ü      Los TSSs no pueden evitarse dado que la tansición de protección requiere un cambio de pila, que se realiza desde el TSS actual. Los descriptores TSS residen en la GDT.

ü      Si se mantiene un modelo plano de memoria, los servicios del sistema pueden invocarse mediante interrupciones (para evitar las llamadas “far” necesarias con las puertas de llamada).

ü      Los sistemas basados en mensajes suelen utilizar pocas llamadas al sistema (send, receive), que pueden ser llamadas igualmente con interrupciones o puertas

ü      de llamada, pasando los parámetros a través de los registros.

 

 

 

 

 

 

 

 

 

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Caso 4:

 

El SO se ejecuta a nivel 0, las bibliotecas del sistema a nivel 1, los manejadores de dispositivos a nivel 2, y muchas tareas a nivel 3, cada una de ellas con múltiples segmentos. Este caso es una variante complicada del anterior (más descriptores y más niveles) y requiere más esfuerzo de implementación. Este caso puede ser el mejor para sistemas de alto rango más que para sistemas empotrados. Comparado con el Caso 3:ç

ü      Las bibliotecas del sistema son accedidas a través de puertas de llamada que residen en la GDT, permitiendo que estén disponibles a todas las tareas. Las bibliotecas de aplicación pueden esconderle a las tareas esas puertas de llamada.

ü      Los manejadores de dispositivos tienen sus segmentos de código y datos a DPL 2 en la GDT (si son públicos) o en su propia LDT (si solo son accesibles por el kernel).

ü      Cada tarea tiene su propio TSS, y en este caso, la conmutación a través de TSS podría estar justificada dado que tenemos que cambiar todos los registros.

 

Un sistema como este es difícilmente justificable dado que puede simplificarse y puede potenciarse utilizando paginación, que es el tema del próximo apartado.

 

Paginación

 

La segmentación ofrece flexibilidad y protección pero presenta varias restricciones: puede incrementar seriamente la complejidad cuando se utilizan muchos segmentos de memoria; la conmutación de registros de segmentos incrementa el tiempo de ejecución; cada segmento tiene un espacio de direcciones estático y limitado; y las herramientas de desarrollo deben soportar también modelos segmentados de memoria. Una alternativa es preservar las principales ventajas de la segmentación (memoria virtual y protección), pero sustituir todos los segmentos por un especio de direcciones plano y flexible. Esto es posible con la paginación.

            La paginación es ideal para aplicaciones multiprogramadas que requieren espacios de direcciones muy grandes o compartir muchos datos. También es sistemas empotrados donde pueden encajar en sistemas como pocos megas de RAM o ROM. Algunos dispositivos actuales gama alta, como dispositivos TV web, pueden ejecutar muchas instancias del mismo navegador. La paginación permite que el código de todos estos navegadores se comparta en lugar de ser duplicado, liberando así memoria. Además, estos navegadores pueden necesitar grandes espacios de direcciones para cargar páginas con mucho texto, imágenes o sonido. La paginación nos permite almacenar y acceder a estos megabytes de memoria de forma fácil a través de un espacio de direcciones plano.

            La paginación ofrece muchas ventajas:

ü      Es un modelo de memoria simple – Cada tarea tiene un espacio de direcciones grande y uniforme. La segmentación puede ignorarse, lo que simplifica el desarrollo de aplicaciones. El desarrollo de herramientas también se simplifica dado que el modelo plano de memoria es mucho más fácil de manejar que uno segmentado.

ü      Las tareas tienen una huella (footprint) pequeña -  El espacio físico que debe ser asignado a una tarea es directamente proporcional al número de páginas que necesita, a diferencia de la segmentación que requiere una cantidad fija de memoria. Las páginas pueden residir en cualquier parte de memoria y no tienen que se contiguas.

ü      Es un forma asignación de memoria eficiente – Las páginas pueden asignarse/desasignarse al vuelo, expandiendo o recortando pilas o heaps de tareas. Las páginas pueden ser compartidas entre tareas y pueden ser remplazadas al vuelo. Por ejemplo, una tarea en ROM puede modificarse parcial o totalmente añadiendole nuevas páginas en memoria flash y reorganizando el espacio de la tarea para que utilice estas nuevas páginas.

 

Paginación en el x86

 

Los x86 utilizan una paginación a dos niveles, como muestra la Figura. Cualquier dirección lógica (segmento/desplazamiento) se traduce en una dirección lineal de 32 bits a través de la segmentación. Cuando se activa la paginación, la dirección lineal se trocea en tres componentes: los 10 bits iniciales son el índice de directorio de páginas, los 10 bits siguientes el índice de páginas y los 12 restantes el desplazamiento.

 

 

 

 

 

 

 

 

 

 

Cuadro de texto:  

 

 

 

 

 

 

 

El SO debe crear e inicializar, para cada tarea, un directorio de páginas (PD) y al menos una tabla de páginas (PT). Solo puede haber en un instante dado un directorio de páginas, indicado por el registro CR3. La página directorio de 4K contiene 1024 entradas de 4 bytes, denominadas entradas del directorio de páginas. El índice directorio de 10 bits en la dirección lineal es un índice en esta tabla para una PDE específica. Esta PDE a su vez contiene la dirección de la tabla de páginas, la cual contiene 1024 entradas de 4 bytes denominadas entradas de la tabla de páginas (PTE). Los 10 bits del índice de páginas actúan como índice dentro de esta tabla de páginas para una PTE específica. Esta PTE apunta a un marco de página (PF), también de 4K, que contiene código o datos de la tarea. El desplazamiento de 12 bits es un desplazamiento dentro del marco. Al final, la dirección de 32 bits apunta a un byte específico de una página.

            Observar que una página tiene 4K y una entrada de página tiene siempre 32 bits. La dirección de una página se obtiene tomando los 20 primeros bits y añadiéndole 12 ceros como desplazamiento. En consecuencia, las páginas siempre están alineadas a 4K.

            Vamos a ver como un hipotético SO crea y gestiona esas páginas sobre un x86. Comenzaremos con un super-simplificado ejemplo: este ejemplo no es real ya que la paginación añade sobrecarga que si no la usásemos, pero explica los conceptos de paginación. 

            Supongamos que una tarea de 32K esta preparada para cargarse y ejecutarse. El SO comienza por crear el directorio de páginas. Incluso en este caso que solo usamos una entrada debemos de asignar una página completa. La dirección de esta página se almacena en el registro CR3. El sistema identifica la primera página de código que se va a ejecutar, basándose en el punto de entrada de la tarea (normalmente escrito en alguna parte del ejecutable). El SO también crea la tabla de páginas que apunta a esa página de código, y almacena la dirección de la dirección de esta tabla en la entrada adecuada del directorio de páginas (Figura).

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 


Se le da el control a la tarea, que envía la dirección de la primera instrucción a la CPU para ejecutarla. La CPU traduce esta dirección separándola en los índices de directorio de páginas, de página y desplazamiento. Como el SO preparó cuidadosamente el directorio de páginas y la tabla de páginas, la instrucción es localizada, traída, y ejecutada, y así con la siguiente.

            Cada entrada del directorio y las tablas de páginas tiene un bit de presencia, que esta inicialmente a 0. El bit se activa si una de las entradas contiene una dirección válida de una página en memoria; contiene un 0 si la página no ha sido inicializada o no es válida. Supongamos que la tarea salta 8K hacia delante. La instrucción destino es decodificada por la CPU, pero esta vez el bit de la tabla de páginas esta a cero, ya que el código no ha sido aun cargado. Esta condición genera automáticamente una excepción de falta de página. El SO reacciona analizando la dirección para comprobar que la dirección es válida pero que no esta en memoria. En este caso, la página es cargada en memoria y la tabla de páginas actualizada, y la instrucción re-arrancada. Este método se denomina demanda de página.

            Ahora, vamos a ejecutar otra tarea. De nuevo el SO, prepara y ajusta el directorio de páginas y las tablas de páginas para la nueva tarea. Cuando se va a ejecutar la nueva tarea se recarga CR3 con la dirección base de su directorio de páginas, de esta forma, una tarea solo puede ver su código y sus datos, y no puede alterar los de los demás.

            ¿Qué ocurre si finalmente el SO agota la memoria? Es decir, ¿qué ocurre cuando se dispara una falta de página por que una tarea quiere ejecutar una página no cargada? El SO necesita descargar aquellas páginas no utilizadas. Con forme una tarea se ejecuta, parte del código ejecutado no volverá a ser ejecutado de nuevo. El sistema operativo no puede predecir si una página será usada o no, pero puede conjeturar que páginas van a ser probablemente menos usadas. Estas páginas son desasignadas (el bit de presencia de las entradas de las tablas de páginas que apuntan a éstas se pone a 0). Las posiciones de estas páginas están disponibles para cargar otras páginas que son demandadas. El SO intenta reducir las faltas de páginas, que pueden convertirse en una fuente de sobrecarga. Imagina si se dispara una interrupción y el manejador no esta en memoria – el retraso para cargar las páginas es sencillamente inaceptable. Una solución a este caso es asegurarse que los manejadores de interrupciones están siempre en memoria y nunca se desasignan (como ocurre con la mayor parte del kernel). Pero invariablemente, se producen numerosas faltas de páginas ya que la ejecución es impredecible.

            Pero mediante la asignación y desasignación cuidadosa de páginas, el SO puede mantener en memoria las páginas requeridas por las tareas. Manteniendo unas pocas de páginas de cada tarea, el SO es capaz de ejecutar muchas tareas, incluso si el tamaño total de estas excede con creces la memoria física disponible. La paginación ofrece un espacio de memoria virtual de 4GB a cada tarea, si bien solo una fracción de este reside en memoria en cualquier momento. Pero desde el punto de vista de la tarea, existen 4GB de memoria disponibles.

            Quizá lo más interesante para una tarea es que sólo se involucra un registro en la traducción de direcciones, CR3, que apunta al directorio de páginas. Cuando se produce un cambio de tarea, sólo es necesario recargar CR3 con la dirección del nuevo directorio de páginas, que permite acceder al espacio privado de la nueva tarea. La segmentación no se deshabilita con la paginación, pero si usamos siempre descriptores con dirección base 0 y límite de 4GB, uno puede olvidarse tranquilamente de ella ya que los segmentos nunca cambian.

            Otro plus de la paginación es la posibilidad de compartir páginas fácilmente. Supongamos que el mismo programa se esta ejecutando dos veces. Un ejemplo puede ser una tarea que monitoriza un dispositivo analógico: en un sistema con dos dispositivos de este tipo habrá dos instancias de esta tarea (una por dispositivo). Dado que el código es el mismo, las dos pueden compartirlo. Esta compartición se alcanza fácilmente cargando una vez el código en memoria y ajustando las tablas de páginas para que apunten a las mismas páginas (Figura 5). Cuanto mayor sea el código compartido mayor la ganancia. Las bibliotecas compartidas son también un buen candidato para la compartición de código. Los datos de escritura no son compartibles (aunque si son compartibles antes de su modificación, ver archivos proyectados en memoria).

 

 

 

 

 

 

Cuadro de texto:  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


            En general, a pesar de las características implementadas en la CPU, el reto esta en diseñar cómo el sistema gestiona las páginas, qué páginas son desasignadas, cuantas páginas de una tarea deben asignarse en memoria a la vez, qué páginas deben anticiparse y preasignarse para acelerar la ejecución, etc. La CPU nos da las herramientas, pero nosotros debemos preparar un buen diseño de sistema de antemano.

 

Estructura del espacio de direcciones de una tarea

 

Aunque cada tarea tiene un espacio de direcciones virtuales de 4GB, es importante particionar esta memoria para diferentes usos. El SO debe reservarse parte de este espacio para él –veremos por qué en breve- y el resto se deja para la aplicación. Normalmente el SO se reserva 1 ó 2 GB en la parte y se dejan a la aplicación 2 ó 3 GB respectivamente en la parte alta. En un modelo simplificado, el código de la aplicación comienza normalmente en la en la base del espacio de direcciones, seguido de los datos; la pila normalmente comienza al final del 1 ó 2 GB (debajo del espacio reservado al SO); y el heap (para datos asignados dinámicamente) se sitúa entre los datos y la pila. Otras combinaciones son aceptables, dependiendo de las necesidades del sistema. Un concepto importante es que todo el espacio de direcciones es virtual; cuando una tarea comienza a ejecutarse o accede a los datos, las páginas físicas son asignadas una por una, según se necesiten. La estructura del espacio de direcciones es un aspecto que afecta al SO, al compilador, y al enlazador, pero no al desarrollador de aplicaciones.

            Como cada aplicación ve sólo sus 4GB de espacio, para que sean accesibles los servicios del kernel (invocados por una aplicación o interrupción) deben proyectarse dentro de este rango en todas las tareas. Las llamadas al sistema pueden implementarse como puertas de llamada o interrupción, en tanto que apunten al manejador adecuado en el espacio de direcciones de cada tarea.

            Cuando se invoca una llamada al sistema, el kernel comienza a ejecutarse en el contexto de la tarea que ha realizado la llamada o ha sido interrumpida. Si se pasan algunos argumentos en la llamada (incluso punteros), estos pueden utilizarse como tales para acceder a los datos de la tarea.

 

Proyectando el sistema operativo

 

Tras la inicialización, el SO debe construir un directorio de páginas y una tabla de páginas iniciales para activar la paginación. Estas podría pertenecer a un monitor permanete, depurador, o sencillamente el bucle vacío. El código y datos del SOs son los marcos de páginas (si el SO esta cargado por completo). El SO puede proyectarse él mismo desde la dirección lineal 0, pero también desde la dirección, por ejemplo, F0000000h (Figura 6). Todas las puertas de llamada y manejadores de interrupción se ajustan por debajo de la dirección F0000000h, lo que da su ubicación real en memoria física.

 

Figura 6.- El SO se proyecta en las direcciones 0 y F0000000h. Dos entradas en la PD apuntan a la misma PT, y por tanto, a la misma memoria física.

 

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 


Cuando se construye la primera tarea, la parte superior de su directorio de páginas se proyecta en todas las tablas de páginas del sistema, proyectando el SO dentro del su espacio de direcciones, como muestra la Figura 7. Cuando se ejecuta la tarea, una puerta de interrupción o llamada saltará dentro de alguna parte del SO.

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


             No hay regla que no dicte como proyectar el SO en la parte inferior o superior del espacio de direcciones de la tarea. Sin embargo, muchos SOs se proyectan así mismos en el extremo superior, dejando a la aplicación la parte inferior (las direcciones más pequeñas son las legibles por los humanos). Pero podríamos diseñar un esquema alternativo, dependiendo de nuestras necesidades. Lo que sigue son ciertas cuestiones que debemos considerar.

            Utilizar segmentos planos (dirección base 0 y límite 4GB). Salvo que tengamos restricciones extraordinarias, podemos olvidarnos de la segmentación manteniéndola de esta forma.

            El SO debe ser capaz de acceder a toda la memoria física mientras esta activa la paginación. Dado que el kernel se ejecuta en el contexto de la tarea interrumpida (cualquiera que esta sea), la memoria física entera debe ser proyectada en todas las tarea. En el ejemplo anterior, al proyectar el kernel en F0000000h permite el acceso hasta 256 MB de RAM. Al objeto de soportar, digamos 512 MB de RAM, el SO debe proyectarse en E000000h.

            Las bibliotecas compartidas, si pensamos soportarlas, deben proyectarse en el kernel. Como el kernel esta proyectado en todas las tareas, las bibliotecas lo estarán también. El enlazado dinámico es más fácil si cada biblioteca reside en la misma dirección en cada tarea. En el ejemplo anterior, el espacio entre C0000000h y F0000000h (768MB) es un buen lugar para ello.

            La reserva de espacio para el uso del sistema reduce el espacio de direcciones de todas las tareas. Algunos sistemas se reservan 2GB del espacio (mitad superior) para ellos, lo que le permite al diseñador del SO un amplio espacio para implementar características y futuras versiones sin cambiar la arquitectura.

 

La protección re-visitada

 

A nivel de paginación también existe la protección, además de la protección siempre presente de la segmentación. Las entradas del directorio de páginas y de la tabla de páginas tiene dos bits de protección: solo-lectura, y el privilegio requerido (CPL) para acceder a la página (0 para acceder en modo supervisor, o mayor que cero en modo usuario). Las entradas de páginas del sistema operativo estan siempre marcadas como modo supervisor, mientras que las entradas de las tablas de páginas de la tarea lo están como usuario. Si una tarea con CPL 1, 2 ó 3, intenta acceder a una página marcada como modo supervisor, incluso solo para leerla, se produce una excepción (y el SO destruye la tarea).

            EL CPL de cada tarea sigue estando dictado por el DPL del segmento de código. Un diseño simple y eficiente sigue siendo utilizar un DPL de 3 con descriptores de tareas y 0 con el SO. Así, la combinación de las características de protección de la segmentación y la paginación suministra un escudo efectivo para los recursos del sistema.

 

Activando la paginación

 

            La paginación se activa una vez que la CPU esta funcionando en modo protegido con privilegios totales (CPL 0). Si se desactiva el modo, lo mismo le ocurre a la paginación. El ejemplo siguiente se inicia en modo real, con las interrupciones deshabilitadas. Entonces se activa el modo protegido y se conmuta a 32-bits (como se mostraba en el primer ejemplo). Después, activamos la paginación y proyectamos el kernel al final de su espacio de direcciones (F000xxxx).

            El ejemplo comienza su ejecución en las direcciones físicas bajas (0000xxxx) y finaliza en alguna lugar por encima de F000xxxx. Aquí aparece una cuestión de direcciones, respecto a una aplicación ejecutándose en 0000xxxx y F000xxxx: si una directiva tal como ORG F000xxxx aparece en el programa, la mayoría de los enlazadores intentaran rellenar el vacío entre la instrucción antes de la directiva y las instrucciones que la siguen (en nuestro caso casi los 4Gb). Por tanto, no podemos utilizar esta directiva. La única forma de resolver el problema es ajustar la dirección base de la aplicación a F0000000h utilizando una opción del enlazador (a mayoría de los enlazadores actuales disponen de ella), y traer todas las intrucciones “pro-paginación” a las direcciones bajas restándoles F0000000h, o utilizando direccionamiento relativo. Esta acción solo afecta a unas pocas instrucciones.

            Se necesita un directorio de páginas y una tabla de páginas antes de activar la paginación. Ambos son pre-asignados en el ejemplo (el directorio de páginas en la línea 49 y la tabla de páginas en la 51); estas podrían haber sido asignadas dinámicamente si la ubicación dinámica estuviese disponible. El único requisito es que las páginas deben estar alineadas a límite de 4KB; su ubicación física no es importante.

            El ejemplo se carga en la dirección física 0, así la tabla de páginas se inicializa para cubrir los marcos de páginas físicas desde la dirección física 0. Se inicializa por completo la tabla de páginas, cubriendo hasta 4MB de memoria física (líneas 108-116), si bien en el ejemplo solo se necesitan unas pocas de líneas. La dirección de la tabla de páginas se almacena en la primera entrada del directorio de páginas (líneas 102-103), marcando las direcciones virtuales iguales a las direcciones físicas cuando se activa la paginación (esto de denomina proyección identidad).

            Las direcciones que empiezan con F000 resultan en el índice 960 del directorio de páginas. Al objeto de proyectar el código en la dirección F0000000h, la dirección de la tabla de páginas también se almacena en la entrada 960 del directorio de páginas (líneas 105-106). El directorio de páginas tiene dos entradas que referencian a la misma tabla de páginas, como muestra la Figura 6. Finalmente, se ajusta el registro CR3 a la dirección del directorio de páginas (líneas 121-122). El kernel podría proyectarse en otra ubicación simplemente inicializando adecuadamente el directorio de páginas. Por ejemplo, si el kernel se va a proyectar en la dirección E0000000h, las PDE 896 ,en lugar de 960, apunta a la primera tabla de páginas. El programa podría también haberse enlazado con una dirección base de E0000000h.

            Entonces habilitamos la paginación ajustando el bit 31 de CR0 (líneas 126-128). Desde este punto, todas las instrucciones son decodificadas utilizando la traducción de paginación. La traducción entonces proyecta direcciones virtuales en direcciones físicas. La cola de instrucciones debe limpiarse para evitar algún problema con las instrucciones “prefetched” y pre-paginadas (línea 129).

            El siguiente paso es conmutar a la parte superior del espacio de direcciones. Simulamos un salto haciendo un PUSH de la dirección de la siguiente instrucción y un RET hacia ella (líneas 133 a 134). No se puede hacer un salto relativo dado que el ensamblador no conoce que la mitad de este código esta ejecutándose en 000xxxxx y la otra mitad en F000xxxx.

            Desde este punto, se inicializa el resto del SO. Todas lar puertas de llamada, interrupción, y trampa, deben apuntar a funciones en la parte alta del espacio de direcciones. Finalmente, si se crea una tarea, su entrada 960 del directorio de páginas debe proyectarse en la tabla de páginas del sistema. Así, cualquier referencia a alguna puerta de las direcciones de este rango acabaran adecuadamente en el SO.

 

 

 

 

 

 

 

 

 

 

 

 

Cuadro de texto: 1.  ; Paging.asm
2.  ; Copyright (C) 1997, Jean L. Gareau
3.  ;
4.  ; Este programa demuestra como habilitar la paginación en modo protegido.
5.  ; Se usa un model plano de memoria y una definición de segmentos simplificada.
6.  ;
7.  ; Este programa ha sido ensambladro con MASM 6.11:
8.  ;    C:\>ML ProtMode32.asm
9.  ;
10. ; Al enlazar, la dirección base debe ser BASE (F0000000h en este ejemplo), 
11. ; que es donde se proyecta el código en su espacio de direcciones.
12.
13. BASE EQU      0F0000000h       ; Direc. base (virtual)
14.
15.              .386P                     ; Uso de intrucciones privilegiadas 386+
16.
17. ;-------------------------------------------------------------------;
18. ; Macros (para usar instrucciones de 32-bit en modo real)                     ;
19. ;-------------------------------------------------------------------;
20.
21. LGDT32       MACRO    Addr             ; 32-bit LGDT Macro en 16-bit
22.              DB       66h              ; 32-bit sustitución de operando
23.              DB       8Dh              ; lea (e)bx,Addr
24.              DB       1Eh
25.              DD       Addr
26.              DB       0Fh              ; lgdt fword ptr [bx]
27.              DB       01h
28.              DB       17h
29. ENDM
30.
31. FJMP32       MACRO    Selector,Offset  ; 32-bit Far Jump Macro en 16-bit
32.            DB      66h            ; 32-bit sustitución de operando
33.            DB      0EAh           ; far jump
34.            DD      Offset         ; 32-bit desplazamiento
35.            DW      Selector       ; 16-bit selector
36. ENDM
37.
38.              PUBLIC   _EntryPoint      ; El enlazador lo necesita.
39.
 
40. _TEXT        SEGMENT PARA USE32 PUBLIC ‘CODE’
41.              ASSUME   CS:_TEXT
42.
43. ;---------------------------------------;
44. ; Directorio y tabla de páginas         ;       
45. ;---------------------------------------;
46.
47.              ORG      3000h            ; => Depende de la ubicación del código.<=
48.
49. PD:          
50.              dd       1024 DUP (0)     ; Directorio páginas:todas las entradas a 0.
51. PT:          
52.              dd       1024 DUP (0)     ; Tabla páginas: todas las entradas a 0.
53.
54. ;--------------------------------------------------------;
55. ; Punto de entrada. La CPU ejecuta en modo real de 16-bit;
56. ;--------------------------------------------------------;
57.
58.              ORG      5000h            ; =>Depende de la ubicación del código.<=
59.
60. _EntryPoint:
61.
62.              LGDT32   GdtDesc - BASE   ; Carga el descriptor GDT
63.
64.              mov      eax,cr0          ; Pone a 0 en registro de control
65.              or       ax,1             ; Pone PE bit (bit #0) en (e)ax
66.              mov      cr0,eax          ; Activa el modo protegido!
67.              jmp      $+2              ; Limpia la cola de intrucciones.
68.
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Cuadro de texto: 69. ; Ahora, la CPU executa modo protegido 16-bit. Hace un far jump para
70. ; cargar CS con un selector a un descriptor de código ejecutable 32-bit.
71.
72.              FJMP32   08h,Start32 - BASE        ; Salta a Start32 (abajo)
73. 
74. ; Estep unto nunca se alcanza. Siguen datos.
75.
76. GdtDesc:                                ; GDT descriptor
77.              dw       GDT_SIZE - 1     ; GDT limite
78.              dd       Gdt              ; GDT dirección base (abajo)
79. 
80. ;----------------------------------------------;
81. ; La CPU ejecuta ahora modo protegido 32-bit.  ;
82. ;----------------------------------------------;
83.
84. Start32:
85.
86. ; Inicializa todos los registros de segmento a 10h (entrada #2 de GDT)
87.
88.              mov      ax,10h           ; entrada #2 en GDT
89.              mov      ds,ax            ; ds = 10h
90.              mov      es,ax            ; es = 10h
91.              mov      fs,ax            ; fs = 10h
92.              mov      gs,ax            ; gs = 10h
93.              mov      ss,ax            ; ss = 10h
94. 
95. ; Ajusta la cima de la pila  para permitir operaciones de pila.
96.
97.              mov      esp,8000h                 ; cimar arbitraria de la pila
98.
99. ; Almacena la dirección de PT en la PDE 0 y 960.
100.
101.             mov      eax,offset Pd - BASE               ; eax  = &PD
102.             mov      ebx,offset Pt - BASE + 3  ; ebx  = &PT | 3
103.             mov      [eax],ebx                          ; PD[0] = &PT
104.
105.             mov      eax,offset Pd - BASE + 960 * 4     ; eax = &PDE[960]
106.
                 mov      [eax],ebx                          ; PD[960] = &PT
107. 
108. ; Inicializa la PT para cubrir los primeros 4 MB de memoria física.
109.
110.             mov      edi,offset Pt - BASE      ; edi = &PT
111.             mov      eax,3                     ; Direcc. 0, ajuste bit p & r/w
112.             mov      ecx,1024         ; 1024 entradas
113. InitPt:
114.             stosd                              ; Escribe una entrada
115.             add      eax,1000h                 ; Direc. siguiente página
116.             loop     InitPt                    ; Loop
117. 
118. ; Activa la paginación mediante:
119. ;   1) ajustar la dirección de PD en CR3 y
120.
121.             mov      eax,offset Pd - BASE      ; eax = &PD
122.             mov      cr3,eax                   ; cr3 = &PD
123. 
124. ;   2) ajustando el bit PG en CR0.
125.
126.             mov      eax,cr0
127.             or       eax,80000000h    ; Ajusta el bit PG
128.             mov      cr0,eax          ; La paginación esta activa!
129.             jmp      $+2              ; Limpia la cola de instrucciones.
130.
131. ; Saltamos ahora a F000xxxx.
132.
133.             push     offset PagingMode ; Mantener direcc. total(F000xxxxh)
134.             ret                          ; Saltar a modo paginación (abajo)
135.
136. PagingMode:
137.
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Cuadro de texto: 138. ;------------------------------------------------------------;
139. ; -> Paginación habilitada, ejecutando código en F000xxxxh <-;
140. ;------------------------------------------------------------;
141. 
142. ; Aquí vienen otras intrucciones de inicialización.
143. ;           ...
144. 
145. ; Estep unto nunca se alcanza. Siguen datos.
146.
147. ;---------------------------------------;
148. ; GDT                                         ; 
149. ;---------------------------------------;
150. 
151. ; GDT (acceso rápido si esta alineada a 4).
152.
153.             ALIGN    4
154. 
155. Gdt:
156.
157. ; GDT[0]: entrada nula, nunca utilizada.
158.   
159.             dd       0
160.             dd       0
161. 
162. ; GDT[1]: código ejecutable, solo lectura, direc. base 0,limite FFFFFh, 
163. ; bit de granularidad (G) activo (hace que el límite sea 4GB)
164. 
165.             dw       0FFFFh           ; Limite[15..0]
166.             dw       0000h            ; Base[15..0]
167.             db       00h              ; Base[23..16]
168.             db       10011010b        ; P(1) DPL(00) S(1) 1 C(0) R(1) A(0)
169.             db       11001111b        ; G(1) D(1) 0 0 Limite[19..16]
170.             db       00h              ; Base[31..24]
171.             
172. ; GDT[2]: Segmento datos,escritura,cubre espacio direc. salvado  GDT[1].
173. 
174.             dw       0FFFFh           ; Limite[15..0]
175.             dw       0000h            ; Base[15..0]
176.             db       00h              ; Base[23..16]
177.             db
         10010010b                          ; P(1) DPL(00) S(1) 0 E(0) W(1) A(0)
178.             db       11001111b        ; G(1) B(1) 0 0 Limite[19..16]
179.             db       00h              ; Base[31..24]
180. 
181. GDT_SIZE    EQU      $ - offset Gdt   ; Tamaño, en bytes
182. 
183. _TEXT                ENDS
184.             END

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Un ejemplo de implementación

 

La cuestión real que se produce cuando implementamos la paginación tiene que ver lo con la estructura del espacio de direcciones, y encontrar el equilibrio adecuado entre espacio de sistema y espacio de tarea, cuando debemos proyectar varios componentes (tareas, servicios del sistema, bibliotecas compartidas,  etc.), qué clase de protección se necesita, etc. Si se soporta intercambio, deberemos identificar el conjunto activo (de trabajo) de la tarea, la estrategia de sustitución, etc.

            Vamos a ver una implementación de un SO multitarea, multihebra (tal como una máquina empotrada Java que ejecuta grandes applets, un servidor Web empotrado o un dispositivo Web TV), ilustrado en la Figura 8.

Cuadro de texto:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Algunas tareas se consideran inseguras y usan su propio espacio plano de direcciones. Todas las hebras de una tarea comparten el mismo espacio de direcciones.

 

Segmentación:

ü      La GDT contiene un descriptor de código y datos para el SO (DPL 0, 0GB a 4GB) y un descriptor de para las tareas (DPL 3, de 0 GB a 4GB). Sin privilegios las tareas no pueden ejecutar instrucciones privilegiadas.

ü      Las llamadas al sistema se implementan bien por puertas de llamada o de trampa, ambas usan el selector del código del kernel y los servicios relevantes diseccionados. Los descriptores de interrupción también utilizan el selector del código del kernel. El selector del kernel permiten que se ejecuten a CPL 0.

ü      No se necesitan LDT dado que el aislamiento se obtiene a través de la paginación.

ü      Se necesita un TSS debido a la transición privilegiada. Las conmutaciones de tareas pueden hacerse salvando los registros en la pila y conmutando de pila, en lugar de utilizar TSS, ya que los registros de segmento nunca cambian. El descriptor TSS esta en la GDT.

Paginación:

ü      Cada            tarea tiene su propio directorio de páginas, que es compartido entre todas sus hebras (por tanto todas las hebras comparten el mismo espacio de direcciones).

ü      El código y los datos utilizan diferentes tablas de páginas, para compartir potencialmente tablas de páginas de código con otras instancias; el puntero a pila se ajusta a 80000000h y crece hacia abajo.

ü      Las entradas del directorio de páginas de la mitad superior de la tarea se marcan todas como “supervisor”. La tarea no puede acceder al código o datos del SO. Esta área de 2GB esta reservada para el SO, las bibliotecas compartidas, y las proyecciones hardware (memoria de video, por ejemplo).

 

 

Referencias:

 

-          Jean Gareau, “Embedded x86 Programming: Protected Mode”, Embedded Systems Programming, April 1998, pgs. 80-93.

-           Jean Gareau, “Advanced Embedded x86 Programming: Protection and Segmentation”, Embedded Systems Programming, May 1998, pgs. 72-87.

-          Jean Gareau, “Advanced Embedded x86 Programming: Paging”, Embedded Systems Programming, April 1998, pgs. 80-93.