por Dario Alejandro Alpern
Hola. Mi nombre es Dario Alpern y hoy vamos a ver multitarea en procesadores Intel de 32 bits.
Para entender cómo funciona la multitarea, veremos un esquema genérico de sistema operativo.
Un sistema operativo maneja el hardware de las computadoras, como la memoria, los discos, teclado, mouse y otros periféricos, y también recursos de software.
De esta manera, si un programa necesita, por ejemplo, imprimir un documento, no maneja directamente la impresora, sino que a través de órdenes dadas al kernel del sistema operativo, éste encola el trabajo, para que varios programas puedan acceder a la impresora. Estos comandos siguen un protocolo denominado API (Application Programming Interface), que depende del sistema operativo específico que estemos usando.
La comunicación entre el kernel y el hardware se realiza mediante drivers, que es un software que permite abstraer el tipo de hardware. Por ejemplo, el kernel maneja todas las impresoras de la misma manera, y el driver permite acomodar las diferencias que existen entre los comandos de bajo nivel que aceptan las distintas impresoras.
Una aplicación es un programa o conjunto de programas que realiza tareas útiles para el usuario. Ejemplos de aplicaciones pueden ser un procesador de textos, un navegador Web o un juego.
Un programa es un conjunto de instrucciones que realiza una tarea determinada que puede ser simple o compleja. Un proceso es una instancia de programa en ejecución. Por ejemplo, nosotros podemos abrir tres calculadoras en la pantalla, lo que significa que hay tres procesos corriendo que corresponden al mismo programa.
Por lo tanto se puede ver que a una aplicación le corresponde uno o más procesos.
Los threads permiten correr procesos de manera concurrente. Muchos algoritmos se pueden ejecutar en paralelo acelerando su ejecución. Los threads comparten memoria y otros recursos, a diferencia de los procesos que son independientes entre sí.
Estos threads pueden estar despiertos cuando están haciendo algo, o bien pueden estar dormidos, cuando esperan por entrada de teclado o mouse u otro periférico. En el ejemplo de la calculadora, la mayor parte del tiempo los threads están dormidos, hasta que el usuario aprieta un botón y el proceso muestra el dígito ingresado o realiza el cálculo pedido. Luego se vuelve a dormir.
Un sistema operativo multitarea es capaz de ejecutar durante una fracción de segundo cada thread que esté despierto. Así se van turnando la ejecución todos los threads despiertos dando la impresión que los threads están corriendo simultáneamente.
Pero en realidad en un momento determinado, el procesador está ejecutando un thread específico.
Para que al usuario le dé la impresión de ejecución simultánea, el ciclo de ejecución de todos los threads que estén despíertos debe durar menos de una décima de segundo. En Linux los pasajes se hacen 100 veces por segundo.
Desde el punto de vista del procesador todos estos threads son tareas, que es un código que se ejecuta asincrónicamente con respecto a otras tareas.
Se debe tener en cuenta que la misma tarea puede estar ejecutando código de aplicación con CPL=3 o de kernel con CPL=0, ya que la aplicación puede hacer una llamada al sistema provocando un cambio de nivel de privilegio.
En este video vamos a ver cómo el procesador ayuda al sistema operativo en el cambio de tareas, es decir, pasar de una tarea despierta a la siguiente tarea despierta que debe correr.
En el diagrama se puede ver el segmento de estado de la tarea o Task State Segment (TSS) con los campos SSn y ESPn, registros del procesador incluyendo registros de uso general, de segmentación, LDTR, EFLAGS, el puntero de instrucciones EIP y el puntero a las tablas de paginación que es el registro de control CR3.
En el caso de cambio de nivel de privilegio, cuando ocurre una llamada intersegmento, una interrupción o una excepción, el procesador cambia a otro segmento de mayor privilegio, es decir menor numéricamente. Para ello se usan los campos SSn y ESPn, que son las direcciones lógicas de los punteros de pila de los niveles de privilegio cero, uno o dos.
Los campos con nombres de registros se utilizan para almacenar los registros del procesador cuando ocurre un cambio de tarea automático (que se verá más adelante), ya que debe recuperarlos cuando vuelve a ejecutar la tarea. Este esquema requiere que haya un TSS por cada tarea.
Con respecto a los registros de segmentación, sólo se salvan en la TSS los selectores. De esta manera, cuando el procesador deba recargar los registros de segmento desde la TSS, si el valor del selector no es cero, habrá una lectura de GDT o de LDT por cada registro de segmento y una lectura de GDT para el registro LDTR.
El campo bitmap de entrada/salida se verá a continuación.
En el caso de ejecutar una instrucción de entrada/salida tal como OUT o IN, el procesador compara el nivel de privilegio del código CPL contra el indicador IOPL ubicado en los bits 13 y 12 del registro EFLAGS. Si CPL es menor o igual que IOPL, entonces se ejecuta la instrucción. Por ejemplo, si CPL vale cero, siempre se puede ejecutar OUT o IN. Si CPL es mayor que IOPL, el procesador consulta el bitmap de entrada/salida, que es un array de bits que permite determinar si se ejecuta la instrucción: si el bit vale cero, se puede ejecutar, y si vale uno o el límite de la TSS es menor que el offset donde debería leerse el bitmap de entrada/salida ocurre una excepción 13.
Ese bitmap de entrada/salida se encuentra en el offset de la TSS indicado en el campo Bitmap E/S que es un campo de dos bytes que está en el offset 0x66 de la TSS.
En este ejemplo vamos a suponer que CPL=3 e IOPL=0, queremos ejecutar la instrucción IN AL,0x60, el campo bitmap de entrada/salida de la TSS vale 0x100 y el límite de la TSS vale 0x200.
Como CPL es mayor que IOPL, hay que consultar el bitmap.
El offset de la TSS correspondiente al byte del bitmap donde el procesador debe leer se calcula sumando al campo bitmap el número de puerto dividido 8. El cálculo es 0x100 + 0x60 / 8, así que deberá leer el offset 0x10C.
El offset calculado es menor o igual que el límite de la TSS, así que el procesador leerá el offset indicado.
Suponiendo que leyó el byte 0x46, el procesador consulta el bit correspondiente al puerto módulo 8. Como el resto de la división de 0x60 dividido 8 es cero, habrá que consultar el bit cero.
El bit 0 del valor leído 0x46 es cero, así que el puerto 0x60 se puede leer.
El formato del Task Register es el mismo que el de los registros de segmento. El selector apunta a un descriptor de TSS que se encuentra en la GDT. Si se intenta acceder a un descriptor de TSS en la LDT, el procesador genera una excepción 13.
Como en el caso de los descriptores de código y datos, el descriptor de TSS tiene los campos de base y límite y el bit de granularidad para indicar la dirección lineal donde comienza la TSS y cuál es su longitud.
El descriptor de TSS tiene un bit B que indica si el procesador está usando el TSS apuntado por el descriptor o no.
Existen dos instrucciones para acceder al Task Register.
Para escribirlo usamos LTR, que significa Load Task Register. Sólo se puede ejecutar con CPL = 0 y carga el selector de registro de tarea con el valor indicado en el argumento de la instrucción.
Para leer el selector de TR usamos STR, que significa Store Task Register. Dicho selector se almacena en el registro o posición de memoria indicado en el argumento de la instrucción.
Sólo se puede ejecutar LTR con un selector cuyo descriptor apunte a un TSS libre. Luego de la instrucción queda ocupado. Como el procesador escribe el bit B del descriptor, es necesario que la GDT esté en memoria RAM.
Aquí se puede ver el formato de la compuerta de tarea. Si la compuerta de tarea se encuentra en la GDT, se puede utilizar con las instrucciones JMP y CALL intersegmento y si se encuentra en la IDT, se puede utilizar con interrupciones y excepciones para generar un cambio de tarea.
Como todas las compuertas, el de tarea tiene una dirección lógica, es decir, selector y offset. En este caso, el selector apunta a un descriptor de TSS y el campo offset no se usa. Por eso los bytes 0, 1, 6 y 7 están reservados.
Al igual que con el resto de las compuertas, el campo DPL determina cuál es el código con menor privilegio que puede usarla. Lo que hace el procesador es comparar DPL contra CPL. Si CPL es mayor que DPL genera una excepción 13. Sin embargo esta comparación no se realiza si la compuerta se utiliza debido a una excepción o una interrupción por hardware.
El scheduler es una rutina del sistema operativo cuyo objetivo es poder lograr que las diferentes tareas corran en el orden esperado por el sistema operativo.
Para ello, el sistema operativo usa listas, donde se encuentra información sobre los diferentes procesos y threads. Lo más relevante para el scheduler es saber si la tarea está dormida o despierta y cuál es su prioridad, es decir si determinada tarea se debe ejecutar antes que otras tareas.
El scheduler se divide en dos partes: la determinación de la tarea a saltar y el salto a la tarea.
En los sistemas operativos cooperativos, las tareas llaman indirectamente al scheduler cuando deben dormir, por ejemplo porque están esperando caracteres de un puerto serie, entrada de teclado, etc.
En los sistemas operativos no cooperativos, donde el scheduler se llama periódicamente y detiene cualquier tarea que esté corriendo y comienza a correr la siguiente tarea no dormida, se pueden distinguir dos casos: scheduler por compuerta de tarea y por compuerta de interrupción.
El cambio de tarea es una rutina que efectúa el scheduler con o sin ayuda del procesador para salvar el estado de la tarea anterior en el contexto de dicha tarea, recuperar el estado del contexto de la nueva tarea y saltar a la nueva tarea a ejecutar.
El contexto de la tarea es el área donde se almacenan los recursos del procesador tales como registros necesarios para que continúe ejecutando la tarea. En el caso de cambio de tarea automático dicha área se encuentra en el TSS. Si el cambio de tarea no es automático, el sistema operativo reservará memoria para el contexto de cada tarea.
En la multitarea cooperativa (cooperative multitasking en inglés), cada tarea decide cuándo pasar el control a la siguiente a través del scheduler.
En la multitarea no cooperativa (preemptive multitasking en inglés), el scheduler se ejecuta en forma periódica gracias a una interrupción del procesador.
El diagrama muestra como el procesador ejecuta un cambio de tarea automática. Se usan dos TSS: uno para la tarea vieja y otra para la nueva que se está por ejecutar.
El registro TR (Task Register) apunta al descriptor de la tarea vieja, mediante el campo selector, y también a la TSS vieja, mediante los campos base y límite.
Lo primero que ocurre en el cambio de tarea automático, es cargar un registro interno TR_bak no visible para el programador, con el selector de la tarea nueva. Esto genera una lectura del descriptor de TSS de la tarea nueva.
Luego que el procesador hace las verificaciones de protección y de los valores del bit de ocupado de ambos TSS, el procesador salva los registros del procesador en el TSS de la tarea vieja, excepto el registro de control CR3.
En el siguiente paso, el procesador carga los registros desde la TSS, esta vez sí incluyendo CR3.
Luego el procesador copia el contenido del registro interno TR_bak en el registro TR, haciendo que éste apunte al TSS de la nueva tarea. Esto no genera una nueva lectura del descriptor de TSS de la nueva tarea porque también se copian los campos base y límite de TR_bak a TR.
Las instrucciones que cambian de tarea en forma automática son las siguientes:
JMP intersegmento cuyo selector apunta a un descriptor de TSS o compuerta de tarea.
CALL intersegmento cuyo selector apunta a un descriptor de TSS o compuerta de tarea.
Instrucción INT, interrupción de hardware o excepción cuya compuerta en la IDT es de tarea.
IRET cuando el indicador NT (Nested Task), que es el bit 14 de EFLAGS vale 1.
En todos los casos luego de ejecutar correctamente el cambio de tarea, el procesador enciende el bit 3 del registro de control CR0 denominado Task Switched.
Cuando la cantidad de tareas del sistema es variable, la instrucción de salto que se usa es indirecto. Esto significa que la instrucción JMP no incluye selector y offset sino un puntero a una estructura que contiene el selector y offset. El scheduler sobreescribe el campo selector de esa estructura antes de saltar.
En la estructura apuntada por la instrucción, el offset ocupa cuatro bytes y el selector dos bytes.
Para especificar que el salto o la llamada intersegmento es indirecto, se utiliza la cláusula FAR entre el nombre de la instrucción y el puntero. Así se escribe JMP FAR [puntero] o CALL FAR [puntero].
Ahora veremos cómo funciona la instrucción JMP usada para el cambio de tareas.
El descriptor de TSS viejo está apuntado por el registro TR. El descriptor de TSS nuevo está apuntado por el selector de JMP o bien por el selector de la compuerta de tarea que está apuntado por el selector del JMP.
El DPL del descriptor de TSS y el de la compuerta de tarea, si se usa, deben ser mayores o iguales que CPL.
El bit de ocupado del descriptor de TSS viejo debe estar a uno, mientras que el del descriptor de TSS nuevo debe estar a cero.
Luego del cambio de tarea automático, se invierten los bits de ocupado: el del descriptor de TSS viejo se pone a cero y el del descriptor de TSS nuevo se pone a uno.
Este esquema no permite cambiar de una tarea a sí misma. Esto previene que se pierdan los registros que se salvan en las TSS.
El registro TR apunta al TSS nuevo al finalizar la instrucción.
En el diagrama se pueden ver tres tareas. Se puede observar que cuando se realiza cambio de tarea mediante la instrucción JMP, todas las tareas tienen el bit de ocupado a cero, excepto la tarea en curso.
Así, en el cambio de tarea 1 a la 2, la tarea 1 se libera mientras que la 2 se ocupa.
La tarea 3 tiene el bit de ocupado a cero porque no participa en el cambio de tarea.
En el cambio de tarea 2 a 3 con la instrucción JMP la tarea 2 se libera mientras que la 3 se ocupa.
La tarea 1 tiene el bit de ocupado a cero porque no participa en el cambio de tarea.
En el cambio de tarea 3 a 1 con la instrucción JMP la tarea 3 se libera mientras que la 1 se ocupa.
La tarea 2 tiene el bit de ocupado a cero porque no participa en el cambio de tarea.
Ahora veremos cómo funcionan las instrucciones CALL o INT, interrupciones de hardware o excepciones usadas para el cambio de tareas.
El descriptor de TSS viejo está apuntado por el registro TR. El descriptor de TSS nuevo está apuntado por el selector de CALL o bien por el selector de la compuerta de tarea que está apuntado por el selector del CALL o que se encuentra en la IDT en el caso de interrupción o excepción.
El DPL del descriptor de TSS y el de la compuerta de tarea, si se usa, deben ser mayores o iguales que CPL.
El bit de ocupado del descriptor de TSS viejo debe estar a uno, mientras que el del descriptor de TSS nuevo debe estar a cero.
Luego del cambio de tarea automático, el bit de ocupado del descriptor de TSS viejo continúa a uno y el del descriptor de TSS nuevo se pone a uno.
Este esquema no permite cambiar de una tarea a sí misma o a otra que haya llamado directa o indirectamente a la tarea vieja.
Esto previene que se pierdan los registros que se salvan en las TSS.
El campo backlink del nuevo TSS se carga con el selector de TSS de la vieja tarea. Así se forma una lista de las tareas que llaman a otras.
El registro TR apunta al TSS nuevo al finalizar la instrucción.
El procesador pone a uno el indicador Nested Task, que es el bit 14 de EFLAGS.
En el diagrama se pueden ver tres tareas. Se puede observar que cuando se realiza cambio de tarea mediante la instrucción CALL, todas las tareas aún no llamadas tienen el bit de ocupado a cero, y la tarea que está corriendo y las que la llamaron directa o indirectamente tienen el bit de ocupado a uno.
Así, en el cambio de tarea 1 a la 2, la tarea 2 se ocupa.
La tarea 3 tiene el bit de ocupado a cero porque no fue llamada aún.
En el cambio de tarea 2 a 3 con la instrucción CALL o INT la tarea 3 se ocupa.
La tarea 1 tiene el bit de ocupado a uno porque llamó a la tarea 2.
No se puede realizar un cambio de tarea de la 3 a la 1 porque está ocupada. Esto es correcto, ya que de no existir el bit de ocupado, se perderían los registros almacenados en la TSS de la tarea 1.
El propósito de la instrucción IRET cuando el indicador NT vale uno es volver a la tarea que se estaba ejecutando antes del último CALL o INT que cambió de tarea.
Es importante tener en cuenta que la instrucción RETF que sirve para volver de una subrutina que se encuentra en otro segmento, no se puede utilizar para cambiar de tarea. Así que la instrucción CALL se aparea con IRET.
El descriptor de TSS viejo está apuntado por el registro TR. El descriptor de TSS nuevo está apuntado por el campo callback del TSS viejo.
Los bits de ocupado de ambos descriptores deben estar a uno.
Luego del cambio de tarea el bit de ocupado del descriptor de TSS viejo se pone a cero y el del TSS nuevo continúa a uno.
El registro TR apunta al TSS nuevo al terminar la instrucción.
En el caso del cambio de tarea 3 a 2 con instrucción IRET el selector del descriptor del nuevo TSS se encuentra en el campo backlink del TSS de la tarea 3.
La tarea 3 que es la vieja se libera, mientras que la tarea 2 continúa ocupada al ejecutar IRET.
La tarea 1 figura como ocupada porque llamó a la tarea 2.
En el caso del cambio de tarea 2 a 1 con instrucción IRET el selector del descriptor del nuevo TSS se encuentra en el campo backlink del TSS de la tarea 2.
La tarea 2 que es la vieja se libera, mientras que la tarea 1 continúa ocupada al ejecutar IRET.
En sistemas multitarea siempre hay una tarea inicial, que es la que se ejecuta antes de configurar el registro TR.
Dicha tarea inicial debe tener su TSS asociado que originalmente debe tener su bit de ocupado a cero ya que la instrucción LTR requiere un TSS libre.
Todos los registros de segmentación y el registro LDTR deben inicializarse con selectores válidos o cero. De esta manera, cuando el procesador vuelva a ejecutar esta tarea, no carga selectores inválidos en registros de segmentación, lo que puede generar excepción 13.
La tarea inicial se puede reutilizar como tarea "idle" que ejecuta un ciclo infinito que contiene la instrucción HLT para ahorrar energía cuando otras tareas no están corriendo.
En este diagrama se puede ver cómo funciona el scheduler por compuerta de tarea.
El scheduler está en una tarea aparte que se llama periódicamente mediante una compuerta de tarea ubicada en la IDT.
Cada vez que ocurre una interrupción del timer, la tarea que está corriendo se detiene y cambia a la tarea del scheduler. En ese momento el procesador escribe el campo backlink de la TSS del scheduler con el selector de TSS de la tarea que estaba ejecutando.
El scheduler determina el selector de la próxima tarea a ejecutar, sobreescribe el campo backlink con ese selector y ejecuta IRET, efectuando un cambio de tarea hacia la tarea que definió el scheduler como destino.
La cantidad de TSS a usar es la cantidad de tareas más una para el scheduler y otra para la tarea inicial.
Se debe inicializar el scheduler y la tarea inicial como desocupados y el resto como ocupados.
Después de ejecutar por primera vez la instrucción IRET, el procesador salva los registros en la TSS del scheduler. Entre otros salva el puntero de instrucción CS:EIP. Dicho puntero apunta a la instrucción siguiente al IRET. De esta manera, cuando se ejecute el scheduler por segunda vez, el procesador va a ejecutar la instrucción que viene después del IRET y por eso allí debe haber un salto al principio de la rutina del scheduler.
Como la tarea del scheduler es independiente del resto de las tareas, no es necesario ejecutar PUSHAD al principio del manejador de interrupción y POPAD al final.
Aquí se puede ver la secuencia de instrucciones para implementar el cambio de tarea usando compuerta de tarea.
Lo primero que debe hacer el scheduler es hallar el selector de TSS correspondiente a la próxima tarea a ejecutar, o tarea idle si no hay ninguna a ejecutar, usando tablas o listas.
Suponiendo que el scheduler obtiene en el registro AX dicho selector, el scheduler carga un puntero al campo backlink del TSS del scheduler. Como ese campo está al principio del TSS, podemos ejecutar la instrucción MOV EBX,TSS_scheduler.
Luego sobreescribimos el campo backlink con el selector del TSS de la nueva tarea mediante la instrucción MOV [EBX],AX.
A continuación cambiamos de tarea mediante la instrucción IRET.
Finalmente volvemos a la primera instrucción del manejador de la interrupción mediante la instrucción JMP scheduler.
En este diagrama se puede ver cómo funciona el scheduler por compuerta de interrupción.
En este caso se usa compuerta de interrupción para el scheduler, lo que no provoca cambio de tarea. El cambio de tarea ocurre cuando se ejecuta una instrucción JMP dentro del scheduler.
Las flechas numeradas en el diagrama muestran el orden en el que ocurren las operaciones.
Suponiendo que se está ejecutando el código de la tarea 1, ocurre la interrupción del timer lo que provoca la ejecución del scheduler. Dicha rutina determina que la siguiente tarea a ejecutar sea la 2. Entonces ejecuta un salto intersegmento usando el selector de esta tarea provocando un cambio de tarea. El scheduler luego ejecuta IRET y comienza a ejecutar el código de la tarea 2.
Cuando ocurre otra vez la interrupción del timer, vuelve a ejecutar el scheduler y esta vez determina que la siguiente tarea a ejecutar es la 1. Ejecuta otro salto intersegmento donde el selector es el de la tarea número 1, provocando otro cambio de tarea. Finalmente la instrucción IRET hace que se ejecute el código de la tarea 1.
En este diagrama figura dos veces el scheduler, pero en realidad está una sola vez en memoria. Lo que ocurre es que en un caso corre dentro de la tarea 1 y en otro caso dentro de la tarea 2.
Como no se puede saltar a la misma tarea, el scheduler debe verificar esta condición antes de intentar ejecutar la instrucción JMP intersegmento. Si continúa ejecutando la tarea, el scheduler debe omitir la instrucción JMP y ejecutar la instrucción IRET para continuar ejecutando el código de la tarea.
La cantidad de TSS a utilizar en el sistema es la cantidad de tareas más una para la tarea inicial.
Todos los descriptores de TSS deben comenzar como desocupados.
Una vez determinado el selector de la TSS de la tarea a saltar, obtenemos el valor del registro TR con la instrucción STR cuyo argumento es un registro de 16 bits. Si el registro coincide con el selector de la nueva tarea, debemos ejecutar IRET inmediatamente. En caso contrario debemos hacer JMP intersegmento usando el nuevo selector y luego usar la instrucción IRET para volver al código de la tarea.
Aquí se puede ver la secuencia de instrucciones para implementar el cambio de tarea usando compuerta de interrupción.
Lo primero que debe hacer el scheduler, como manejador de la interrupción del timer, es salvar los registros de uso general mediante la instrucción PUSHAD.
Luego determina el selector de la tarea a saltar. Supongamos que dicho selector queda cargado en el registro AX.
Debemos obtener el selector de la tarea actual leyendo el registro TR mediante la instrucción STR BX.
Luego comparamos ambos valores y saltamos si son iguales, para no realizar el salto intersegmento. Esto se logra mediante las instrucciones CMP BX,AX y luego JE me_voy.
El cambio de tarea mediante el salto intersegmento se materializa mediante dos instrucciones: primero salvamos el selector de tarea en la estructura que usa el salto indirecto mediante la instrucción MOV [sel_TSS],AX y luego efectuamos el salto indirecto mediante la instrucción JMP FAR [puntero_TSS].
Finalmente terminamos el manejador de la interrupción del timer mediante las instrucciones POPAD e IRET.
En la zona de datos definimos la estructura utilizada por el salto indirecto. Primero se ubica el offset de cuatro bytes y luego el selector de dos bytes.
El modo virtual 8086 es una tarea que corre con CPL=3 donde los registros de segmento operan igual que en modo real. Esto significa que cuando se carga un selector en un registro de segmento, la base se carga con el valor del segmento por 0x10.
Hay dos maneras de entrar al modo virtual 8086.
En el primer caso se puede entrar mediante un cambio de tarea donde el registro EFLAGS tenga el bit 17 encendido. Ese bit se llama VM, de Virtual Mode.
En el segundo caso se puede entrar mediante una instrucción IRET desde CPL = 0 donde la imagen de EFLAGS en la pila tenga el bit 17 encendido.
Si CPL es mayor que cero, entonces el bit VM no muestra su valor real. Siempre indica cero.
En el caso de interrupciones o excepciones, el procesador sale del modo virtual y reingresa mediante IRET como se comentó previamente.
Las instrucciones para habilitar y deshabilitar interrupciones STI y CLI, generan excepción 13. El manejador debe determinar si habilitar o no las interrupciones en el modo virtual.
A partir del procesador Pentium, existen varias optimizaciones del modo virtual 8086 que permiten que corra mucho más rápido, ya que incluye entre otras cosas un nuevo indicador de interrupciones virtual en el registro EFLAGS y no es necesario correr software en el manejador de la excepción 13.
Ahora veremos algunos ítems de protección que realiza el procesador cuando ejecuta diferentes niveles de privilegio. Esto normalmente ocurre cuando se utilizan sistemas multitarea, que requieren diferentes niveles de privilegio para proteger el sistema.
El procesador efectúa dos verificaciones con respecto a las compuertas.
Si no ocurre una excepción o interrupción por hardware, el procesador compara el DPL de la compuerta contra CPL. Si CPL es mayor que el DPL de la compuerta, ocurre una excepción 13.
Por ejemplo, las compuertas de excepción o de interrupción de hardware deben tener DPL=0, para evitar que una aplicación intente llamar a la interrupción mediante la instrucción INT.
Las compuertas de llamada y de interrupción utilizadas para llamadas al sistema deben tener DPL=3 para que la aplicación pueda usar la compuerta.
Si el campo DPL del descriptor al que apunta el selector de la compuerta es mayor que CPL, ocurre una excepción 13.
Esto significa que un código de privilegio cero no puede llamar a otro de privilegio 3, pero sí se puede dar el caso contrario.
Las llamadas al sistema (system call en inglés) permiten que una tarea corriendo con CPL=3 llame a una rutina del kernel para realizar un servicio tal como pedir acceso a memoria, interactuar con hardware, poner a dormir la tarea, etc.
Dichas llamadas se pueden implementar mediante la instrucción CALL intersegmento a través de una compuerta de llamada en la GDT o LDT o mediante una instrucción INT usando una compuerta de excepción en la IDT para no deshabilitar las interrupciones.
La compuerta debe tener DPL=3 para permitir el acceso a la aplicación. En caso contrario se genera excepción 13.
Se debe tener en cuenta que las instrucciones JMP y CALL cuyo selector apunte a un descriptor de código, no cambian el nivel de privilegio. Es decir que la compuerta de llamada es obligatoria en el caso de implementar llamadas al sistema mediante la instrucción CALL.
Para acceder a datos en memoria, se debe tener en cuenta el campo RPL o Requested Privilege Level del selector, que son los bits 1 y 0, el campo DPL (Descriptor Privilege Level) del descriptor, que son los bits 6 y 5 del byte 5 y el CPL o Current Privilege Level, que es el nivel de privilegio del segmento de código, que se obtiene leyendo los dos bits menos significativos del selector de CS.
El Effective Privilege Level es el máximo numérico entre RPL y CPL.
Si EPL es mayor que DPL, ocurre una excepción 13. En caso contrario se efectúa el acceso a memoria.
Para el caso del segmento de pila, los tres valores deben ser iguales: tanto el CPL como el DPL del descriptor de datos y el RPL del selector de SS. En caso contrario ocurre una excepción 13.
En estos diagramas se muestra lo que el procesador pone en la pila cuando hay un CALL mediante compuerta de llamada.
Cuando no hay cambio de nivel de privilegio, el procesador ingresa en la pila el CS del llamador y el EIP del llamador.
Si hay cambio de nivel de privilegio, como el privilegio de la pila es la misma que la del código, el procesador obtiene el puntero de la nueva pila del TSS.
En dicha pila pone el SS y ESP de la pila vieja, luego se copian los parámetros. La cantidad de DWORDs a copiar está indicada en el byte 4 de la compuerta de llamada. Finalmente el procesador coloca en la pila el CS y EIP de la rutina llamadora.
Si debido al byte 4 de la compuerta de llamada el procesador copió parámetros de la pila vieja a la nueva, el retorno de subrutina se debe realizar mediante la instrucción RETF n donde n es la cantidad de bytes que ocupan los parámetros.
Por ejemplo, si el byte 4 de la compuerta de llamada vale 3, significa que el procesador copia 12 bytes entre pilas. Por lo tanto, el retorno de la subrutina debe utilizar la instrucción RETF 12.
Cuando no hay cambio de nivel de privilegio, el procesador pone en la pila el registro EFLAGS y luego el CS y EIP de la rutina interrumpida. En el caso de algunas excepciones, el procesador pone en la pila el código de error.
Si hay cambio de nivel de privilegio, al igual que en el caso de la compuerta de llamada, el procesador busca el puntero de la nueva pila en el TSS, teniendo en cuenta que el privilegio de la pila debe ser igual que el del código.
Luego el procesador salva en la nueva pila los registros SS y ESP que apuntan a la vieja pila, el registro EFLAGS y luego el CS y EIP de la rutina interrumpida. En el caso de algunas excepciones, el procesador pone en la pila el código de error.
El manejador de la rutina de excepción debe retirar el código de error de la pila para poder ejecutar la instrucción IRET.
El cambio automático de tareas visto hasta ahora funciona solamente en procesadores corriendo en modo protegido. Esto significa que es incompatible con procesadores de otros fabricantes y también con el modo largo, que es un modo de operación de los procesadores Intel que permite correr programas en 64 bits.
Los sistemas operativos escritos en forma portable para que funcionen con diferentes procesadores, usan el cambio manual de tareas. En vez de usar un TSS por tarea, se usa un solo TSS, que es necesario para poder realizar cambios de nivel de privilegio y opcionalmente si se necesitan proteger puertos de entrada/salida.
El procesador no va a salvar automáticamente los registros en la TSS sino que el schedular es el encargado de salvar los registros en una zona de memoria de privilegio cero llamado contexto de la tarea. Por eso no se usan las instrucciones JMP y CALL intersegmento cuyos selectores apunten a descriptor de TSS o compuerta de tarea, ni tampoco excepciones o interrupciones de hardware o software cuya entrada en la IDT sea una compuerta de tarea. Debido a esto, el valor del indicador Nested Task o NT del registro EFLAGS siempre va a valer cero.
El procesador debe salvar en el área de contexto los registros que figuran en la TSS junto con el par SS0:ESP0 que sirve para poder cambiar de pila cuando hay un cambio de nivel de privilegio, debido a que el nivel de privilegio de la pila siempre coincide con el nivel de privilegio del código (o CPL).
Existen muchas maneras de salvar los registros en el área de contexto. Aquí vamos a suponer que dicha área se encuentra en la pila de privilegio cero.
El scheduler se llama mediante compuerta de interrupción, así que cuando comienza a ejecutar el manejador, la pila de nivel de privilegio cero ya tiene el puntero de pila de privilegio 3 SS:ESP, el registro EFLAGS y el puntero de instrucciones formados por el par de registros CS:EIP.
Preservamos los registros de uso general mediante la instrucción PUSHAD y luego salvamos en la pila los registros de segmentación, excepto CS que ya fue salvado durante la vectorización de la interrupción. A continuación leemos los valores de los campos SS0 y ESP0 de la única TSS e ingresamos estos valores en la pila.
Ahora debemos salvar el puntero de pila SS:ESP en un array de punteros de pila, en la entrada correspondiente a la vieja tarea.
A continuación, cargamos el registro de control CR3 para que apunte al nuevo directorio de páginas y cargamos el puntero de pila SS:ESP con el contenido del array de punteros de pila, en la entrada correspondiente a la nueva tarea.
Luego debemos recuperar los registros en el sentido inverso en que ingresaron en la pila.
Primero obtenemos los dos primeros valores y grabamos los campos SS0 y ESP0 de la TSS, luego seguimos con los cinco registros de segmentación y finalmente con los registros de uso general usando la instrucción POPAD. Antes de ejecutar esta instrucción, debemos ejecutar la secuencia de End of Interrupt requerida por el PIC.
Finalmente ejecutamos la instrucción IRET para ejecutar el código de la tarea.
Como estamos usando las pilas de privilegio cero para almacenar los valores de los registros para cada tarea, antes de que puedan arrancar las tareas, debemos cargar los valores iniciales de los registros en las pilas de privilegio cero de cada tarea, incluyendo el puntero de pila de nivel de privilegio 3, SS3:ESP3, el registro EFLAGS, y el puntero de instrucciones CS:EIP en el orden esperado por la instrucción IRET.
Debe haber un único TSS que se ocupa mediante la instrucción LTR (Load Task Register). A partir de ese momento, el TSS está siempre ocupado.
Como vimos en la sección de acceso a datos, una rutina de nivel de privilegio 3 no puede leer o escribir un buffer de nivel de privilegio cero.
Para quebrar el sistema operativo, lo que podría intentar la aplicación es instalar un caballo de Troya, haciendo que sea el kernel el que sobreescriba el buffer, ya que el kernel, como tiene nivel de privilegio cero, puede leer y escribir en buffers de privilegio cero.
Sea la función read(int fd, void *buf, int len); que escribe en el buffer buf la cantidad de bytes especificados por len lo que se lee del archivo indicado por el file descriptor fd.
Si una aplicación que tiene CPL=3 llama al kernel para ejecutar esa función y le pasa un puntero a buffer del propio sistema operativo, la función sobreescribirá dicho buffer con el contenido del archivo, posiblemente "inoculando" algún virus informático.
La rutina que atiende la llamada al sistema tiene que ajustar el valor de RPL de los selectores que pasa la rutina llamadora para que tengan el valor del CPL de esa rutina llamadora.
De esta manera, si la función read se llama desde CPL=0, la función podrá escribir en buffers del sistema operativo, pero si se llama desde una aplicación desde CPL=3, ocurrirá una excepción 13 si el código intenta escribir un buffer del kernel.
La instrucción ARPL usa dos registros de 16 bits como operandos. El de la izquierda es el destino y el de la derecha es el fuente. Lo que hace la instrucción es copiar los dos bits menos significativos del registro fuente en el registro destino.
Esto es así porque RPL son los dos bits menos significativos del selector.
Espero que esto les haya sido de interés. Hasta luego.