Introducción a modo protegido

  1. Alpertron
  2. Microprocesadores de la línea Intel
  3. Introducción a modo protegido

por Dario Alejandro Alpern

Transcripción

Hola. Mi nombre es Dario Alpern y hoy vamos a ver introducción al modo protegido.

Esquema de un sistema operativo

Para entender el uso de modo protegido y qué se quiere proteger, 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.

Lo que queremos lograr usando las capacidades de modo protegido, es que los procesos no choquen entre sí o con el kernel o los drivers.

Niveles

Parte de esto se puede conseguir mediante el concepto de privilegio. Vamos a decir que tanto el kernel como los drivers tienen privilegio de kernel, mientras que el resto del sistema tiene privilegio de aplicación.

Un programa corriendo con privilegio de aplicación no puede leer ni escribir datos que tengan privilegio de kernel, y ningún segmento de código se puede escribir.

El procesador posee cuatro niveles de privilegio. En nuestro caso, el privilegio de kernel es el nivel cero, mientras que el privilegio de aplicación es el de nivel 3. No vamos a usar los niveles 1 y 2 de privilegio.

En esta explicación falta indicar cómo hacer que un proceso no sobreescriba o lea datos de otro proceso, ya que ambos tienen privilegio de aplicación.

Tabla local

Para solucionar este problema, el procesador maneja tablas locales por proceso que solo permiten acceder a la memoria de código o datos que corresponde a ese proceso. El procesador sólo ve una de estas tablas locales por vez. Entonces el proceso A no puede escribir datos del proceso B porque la tabla local del proceso A no tiene puntero a datos del proceso B.

Unidad de segmentación

La unidad de segmentación posee 6 registros de segmentos, que sirven para apuntar al código mediante el registro CS (code segment), a la pila mediante el registro SS (stack segment) o a datos mediante los otros cuatro registros de segmento.

Cuando el programador escribe en un registro de segmento, ese valor se interpreta como selector. En modo protegido, el procesador modifica los otros tres campos a partir de datos almacenados en tablas de descriptores.

El procesador también dispone de cuatro registros de direcciones del sistema. Excepto el registro TR, los otros tres apuntan a tablas de descriptores.

El registro LDTR (Local Descriptor Table Register) apunta a la tabla local de descriptores que incluye los punteros a los segmentos que se pueden acceder desde el proceso que se está ejecutando.

El registro GDTR (Global Descriptor Table Register) apunta a la tabla global de descriptores que incluye los punteros a los segmentos que se pueden acceder a cualquier proceso.

El registro IDTR (Interrupt Descriptor Table Register) apunta a la tabla de descriptores de interrupción que incluye una compuerta por cada tipo de interrupción. El procesador puede vectorizar hasta 256 tipos diferentes de interrupción, así que la tabla de descriptores de interrupción tiene como máximo 256 entradas.

El registro TR (Task Register) se utiliza para multitarea y se verá su funcionamiento en otro momento.

El microprocesador soporta protección por segmentación (que es lo que estamos viendo ahora) y protección por paginación (que se va a ver en otro momento). Como vamos a hacer énfasis en la protección por paginación, no vamos a utilizar la tabla local de descriptores.

Tablas de descriptores

En este diagrama se pueden ver las tres tablas de descriptores apuntados por los registros correspondientes.

Cada descriptor ocupa 8 bytes.

Dentro de la tabla global de descriptores vamos a encontrar descriptores de código y datos que sirven para apuntar a segmentos de código y datos, descriptores de LDT, que apuntan a tablas de descriptores locales, compuertas de llamada, que sirven para llamar a rutinas en diferente nivel de privilegio, y también existen otros descriptores más.

En la tabla local de descriptores vamos a encontrar descriptores de código y datos que sirven para apuntar a segmentos de código y datos del proceso que esté corriendo.

La tabla de interrupciones contiene compuertas, que son descriptores que incluyen selector y offset. De esa manera el procesador puede ir a la dirección lógica indicada por la compuerta cuando llega una interrupción. En dicha dirección lógica se deberá encontrar la rutina que maneja la interrupción (en inglés se dice interrupt handler).

Selector

A diferencia del modo real, en el que el programador puede cargar cualquier selector en un registro de segmento, en modo protegido el selector tiene el formato indicado en el diagrama.

Los 16 bits del selector se distribuyen en tres campos.

Los bits 15 a 3 especifican el índice dentro de la tabla GDT o LDT. Si este campo vale cero, el selector se refiere a la primera entrada (que son los offsets 0 a 7 de la tabla), si vale 1, el selector se refiere a la segunda entrada (que son los offsets 8 a F hexadecimal) y así sucesivamente.

El bit 2 indica qué tabla de descriptores va a leer el procesador. Si vale cero, va a leer la GDT, mientras que si vale 1 va a leer la LDT.

Los bits 1 y 0 indican el nivel pedido de privilegio. Por ahora este número coincide con el nivel de privilegio indicado por el descriptor a leer en la GDT o LDT.

Descriptor de código y datos

Aquí podemos ver los 8 bytes del descriptor de código y datos.

Los bytes 2, 3, 4 y 7 contienen la dirección lineal de la base del segmento de código o datos.

Los bytes 0, 1 y la mitad inferior del byte 6 contienen 20 bits del límite. El límite es la longitud del segmento menos 1.

Como el campo límite de los registros de segmento tiene 32 bits, se utiliza el bit 7 del byte 6 que es el bit G, de granularidad para extender el límite a 32 bits como se verá en el siguiente diagrama.

El bit D (Default), cuando vale cero indica que el segmento es de 16 bits y si vale uno indica que el segmento es de 32 bits. Nosotros siempre vamos a usar segmentos de 32 bits.

Segmentos de 16 y 32 bits

En los segmentos de código de 32 bits se invierte el uso de los prefijos 66 y 67 con respecto a los segmentos de 16 bits. Esto quiere decir que si quiero cargar un registro de 32 bits, no se usa el prefijo 66, mientras que si quiero cargar un registro de 16 bits, sí se usa el prefijo 66.

Lo mismo ocurre con el prefijo 67 con respecto a direcciones de 16 o de 32 bits.

Como los códigos binarios son diferentes en segmentos de 16 bits que en segmentos de 32 bits, hay que especificarle al ensamblador qué tipo de registro estamos generando.

Excepto este manejo de los prefijos 66 y 67, el procesador soporta el mismo conjunto de instrucciones y los mismos registros tanto en segmentos de código de 16 bits como en segmentos de código de 32 bits.

Con la directiva USE16 las instrucciones que se ensamblan a continuación son para segmentos de código de 16 bits, mientras que con la directiva USE32 las instrucciones que se ensamblan a continuación son para segmentos de código de 32 bits.

Descriptor de código y datos

A la derecha se ve el byte de derechos de acceso. Esta información se carga en el campo atributos del registro de segmento correspondiente.

El bit 7 debe estar a 1 indicando que el segmento está presente. Los bits 6 y 5 indican el nivel de privilegio del segmento apuntado por este descriptor donde 0 es el máximo privilegio y 3 el mínimo.

Se puede ver que el contenido de los bits 3, 2 y 1 dependen si el descriptor es de código o de datos.

En el caso de descriptor de código, el bit 3 vale 1. El bit 2 indica si el segmento de código es conforme o no. Un segmento conforme es aquel que cuando lo llaman, mantiene el nivel de privilegio del que lo llamó. Estos segmentos siempre tienen DPL = 0. Si lo llama un segmento de privilegio de kernel, este segmento de código seguirá con este privilegio, mientras que si lo llama un segmento de privilegio de aplicación, el segmento conforme se comportará como de aplicación. Cuando el segmento no es conforme, su nivel de privilegio es el indicado por el campo DPL del descriptor. El bit 1 indica si el segmento de código se puede leer o no. Un segmento de código nunca se puede escribir.

En el caso de descriptor de datos, el bit 3 vale cero. El bit 2 indica la dirección de expansión del segmento de datos. La dirección de expansión indica qué offsets son válidos. Si la dirección de expansión vale 0, que es lo normal, entonces el offset debe ser menor o igual que el límite. Si vale 1. el offset debe ser mayor que el límite. El bit 1 es el permiso de escritura. Un segmento de datos siempre se puede leer.

El bit cero indica si el segmento fue accedido. Hay que inicializar este bit a cero.

Cálculo del límite

En el descriptor, el límite ocupa 5 de los 8 dígitos hexadecimales o nibbles. El bit de granularidad indica como se rellenan los otros tres nibbles. Si el bit de granularidad vale cero, entonces la parte alta del límite se rellena con ceros.

Si el bit de granularidad vale uno, el límite se multiplica por 4096, poniendo 3 "efes" a la derecha del límite.

Ejemplo

Definir descriptor de datos en la entrada 3 de la GDT que tenga base 33500000 hexa y límite 00192FFF hexa que se pueda leer y escribir y tenga DPL = 0. Indicar cuál es el selector que le corresponde.

Ejemplo 1

Usando la base y el límite completamos a la derecha los valores que corresponden al descriptor de datos.

Comenzando por la base, el byte más significativo es 33 hexa, así que va en el offset 7 del descriptor. El siguiente byte, 50 hexa, va en el byte 4 y luego 00 en el byte 3 y el byte 2.

Siguiendo con el límite, como termina en tres efes, la granularidad vale 1 y los cinco nibbles a poner en el descriptor son 00192 hexa. En el byte 6 pondremos el bit 7 de granularidad a uno, el bit 6 de default a 1 porque dijimos que vamos a usar segmentos de 32 bits, y el nibble más significativo del límite es cero, por lo que el valor del byte 6 debe ser C0 hexa. El byte 1 son los dos nibbles siguientes, 01 hexa y el byte 0 es el byte menos significativo del límite. Eso nos da 92 hexa.

El byte de derechos de acceso tenemos el bit 7 de presente a uno, los bits 6 y 5 de DPL a 00, el bit 4 a uno, que es fijo para descriptores de código y datos, el bit 3 a cero, porque es un descriptor de datos, el bit 2 de dirección de expansión a cero, que es lo normal, el bit 1 que es la habilitación de escritura a uno y finalmente ponemos el bit 0 de accedido a cero.

En la parte inferior del diagrama se puede ver la directiva define byte que vamos a usar en nuestro código fuente para definir este descriptor, indicando los 8 bytes que pusimos en la columna de la derecha, desde el offset cero hasta el offset 7.

Cálculo del selector

Para calcular el selector, debemos descomponerlo en los tres campos. Como dice el enunciado, el índice vale 3. El indicador de tabla vale cero porque el descriptor debe estar en la GDT. El RPL vale cero para que coincida con el DPL del descriptor.

Esto significa que el selector vale 0018 hexa.

Se puede ver un ejemplo para cargar el data segment con este selector.

Y finalmente podemos ver cómo quedan los cuatro campos del registro de segmento luego de haber cargado el selector 0018 hexa.

Compuertas

Las compuertas son descriptores que contienen selector y offset, es decir una dirección lógica. Estas compuertas pueden apuntar a manejadores de interrupciones o excepciones, o bien a subrutinas en el caso de compuerta de llamada.

En el diagrama se puede observar los seis bytes donde se ubican el selector y el offset.

Excepto la compuerta de llamada que va en la GDT, las otras tres compuertas se ubican en la IDT.

Carga de registros GDTR e IDTR

Como las tablas GDT e IDT tienen dirección lineal de inicio y longitud, tenemos que indicar esta información al procesador para que los almacene en los registros GDTR e IDTR.

La carga de estos registros es muy particular y requiere que ambos parámetros estén en memoria, en una estructura denominada imagen de GDTR o imagen de IDTR.

En ambas estructuras, primero se ubican los dos bytes del límite y luego los cuatro bytes de la base, especificados en el formato little-endian.

Una vez que se completaron las imágenes, se procede a la carga de estos registros especiales mediante las instrucciones LGDT y LIDT, especificando como parámetro el puntero a la imagen.

Registro CR0

El registro CR0 es un campo de bits. Los dos bits más importantes son el bit cero para habilitar el modo protegido y el bit 31 para habilitar la paginación. No se puede activar la paginación sin activar también el modo protegido.

Cuando se modifique CR0 hay de asegurarse que no se toquen otros bits. La secuencia a usar es la que se ve abajo.

Pasaje a modo protegido

Aquí se ven los pasos necesarios para pasar a modo protegido. Es sumamente importante que las interrupciones estén deshabilitadas durante todo este proceso.

Espero que les haya sido de interés. Hasta luego.