StellatorOS: Entrando a modo protegido 32bits


Para aprovechar la totalidad de la CPU debemos pasar a modo protegido de 32 bits, las funcionalidades extra que nos ofrece este modo son:

  • Registros de 32 bits
  • Dos registros de propósito general adicionales, fs/gs
  • Capacidad de acceder hasta direcciones de memoria 0xffffffff: 4GB
  • Un sistema de memoria segmentada mas avanzado que nos permitirá proteger ciertos segmentos para que las aplicaciones de usuario no tengan acceso a estos
  • Un sistema de memoria virtual para swapear los datos menos utilizados a disco
  • Una gestión de interrupciones mas sofisticada

Antes de comenzar es recomendable que leas estos artículos anteriores:

El contenido expuesto en este artículo es muy denso, si no lo comprendes a la primera es normal, leelo tantas veces como precises y poco a poco todas las piezas acabarán encajando.

Para pasar a dicho modo tendremos que preparar una GDT: global descriptor table, donde definiremos los segmentos de memoria y sus atributos, esto es necesario si queremos utilizar lenguajes de programación de alto nivel como C.

Además una vez pasemos a 32 bits ya no podremos volver a modo 16 bits, ya no podremos hacer llamadas a las interrupciones de la BIOS ya que estas fueron pensadas para trabajar en modo 16bits, si lo intentamos crashearemos el equipo.

NOTA: Un SO de 32 bits es capaz de volver a modo 16 bits pero esto dá mas problemas de los que solventa sobretodo por temas de rendimiento.

Algo muy importante es que al utilizar GDT ya no haremos referencia a las posiciones de memoria indicando el segmente:offset si no que tendremos en RAM una tabla con descriptores de segmento, accediendo al descriptor de segmento obtendremos la dirección base del segmento al que queremos acceder.

Cada descriptor de segmento posee la siguiente información:

  • Dirección base(32 bits): Define donde empieza el segmento
  • Límite del segmento(20 bits): Define el tamaño del segmento
  • Varios flags: Nivel de privilegio(ring), si ese segmento es RW/RO...

Los bits de flags tienen en siguiente significado:

P: Present bit. Debe ser 1 para todos los selectores válidos.

DPL: Privilege, 2 bits. Contiene el ring level, 0-kernel 3-userspace.



S: Descriptor type. 1 para segmentos de código o datos, 0 para traps/interrupciones/tasks.

Code: Si se setea a 1 se permite la ejecución de código en este segmento. Si vale 0 se trata de un segmento de datos.

Conforming:
Si se trata de un segmento de datos este bit indica la dirección en la que crecerán los datos, si vale 0 crecerá hacia direcciones de memoria inferiores, en caso contrario hacia superiores.
Si se trata de un segmento de código, un valor de 0 indica que el código de este segmento no podrá ser ejecutado desde un nivel de privilegios inferior, de este modo podemos proteger los segmentos que utilizará el kernel del userland.

R/W:
Si se trata de un segmento de datos este bit indica si se puede escribir en él, la lectura siempre está permitida en un segmento de datos.
Si se trata de un segmento de código este bit indica si se puede leer desde él, la escritura nunca está permitida en un segmento de código.

Accessed: Se utiliza con frecuencia para debugear, cuando la CPU accede a este segmento lo setea a 1.

Gr: Granularity bit. Indica la granularidad de los segmentos, si vale 0 los límites de los segmentos están expresados en unidades de 1B, si vale 1 en unidades de 4KB

D/B: Si lo seteamos a 1 estamos indicando que el segmento contendrá datos de 32bits, en caso contrario de 16 bits.

L: No se utiliza en modo 32bits.

AVL: Este bit puede ser utilizado para nuestros propios propósitos, por ejemplo debugging, pero no lo vamos a utilizar.

Un descriptor de segmento tiene el siguiente aspecto:

Como podemos ver el orden de los datos  es totalmente ilógico, la dirección base está fragmentada en tres partes teniendo que unirlas para formar la dirección total:

16-31: 16bits
0-7: 8bits
24-31: 8bits

En total 32bits: 16+8+8=32bits

Como decía una absoluta locura, supongo que se deberá a alguna restricción del hardware o para aprovechar alguna característica del hardware para realizar alguna operación sobre estos datos o alguna explicación que escapa a mí entender, como podemos ver en la imagen pasa lo mismo con el límite de segmento.

Nosotros vamos a adoptar el sistema explicado en el Intel’s Developer Manual: basic flat model, este consiste en dos segmentos de memoria, uno para datos y otro para código pero cuyas direcciones se solapan, de este modo será todo mas sencillo, no habrá protección entre segmentos, ni paginación, ni memoria virtual. Ambos segmentos serán del máximo tamaño direccionable con 32 bits: 2^32=4294967296 bits -> 4GB y se podrán utilizar tanto para datos como para código ya que vamos a configurar las direcciones de inicio y fin para que se solapen.

Además de las entradas de datos y código la CPU requiere que la primera entrada de la tabla de descriptores de segmento sea inválida, una estructura de 8 bytes de ceros.

Esta entrada inválida es un mecanismo de detección de errores en caso de que olvidemos setear el registro de segmento antes de acceder a la dirección de memoria, si no seteamos el segmento este valdrá 0, se utilizará el descriptor de segmento 0 de la GDT con datos incorrectos y la CPU saltará con un error de excepción.

En ensamblador simplemente debemos definir variables del tamaño adecuado con los valores correctos según el tipo(código/datos) de segmento que estemos definiendo.

vi 32bit-gdt.asm
; null GDT descriptor
gdt_start:
    dd 0x0 ; 4 byte
    dd 0x0 ; 4 byte

; GDT for code segment.
gdt_code: 
    dw 0xffff    ; segment limit, bits 0-15
    dw 0x0       ; segment base, bits 0-15
    db 0x0       ; segment base, bits 16-23
    db 10011010b ; flags (8 bits)
    db 11001111b ; flags (4 bits) + segment limit, bits 16-19
    db 0x0       ; segment base, bits 24-31

; GDT for data segment. base and length identical to code segment(overlap)
gdt_data:
    dw 0xffff
    dw 0x0
    db 0x0
    db 10010010b
    db 11001111b
    db 0x0

gdt_end:

; GDT descriptor
gdt_descriptor:
    dw gdt_end - gdt_start - 1 ; gdt size (16 bit), always one less of its true size
    dd gdt_start ; gdt start address (32 bit)
    
; define some constants for later use
CODE_SEG equ gdt_code - gdt_start ; segment descriptor of code segment
DATA_SEG equ gdt_data - gdt_start ; segment descriptor of data segment

Analicemos las flags del segmento de código:

    db 10011010b ; flags (8 bits)

P: 1
DPL: 00
S: 1
Type(4bits):
    Code: 1
    Conforming: 0
    R/W: 1
    Accessed: 0

    db 11001111b ; flags (4 bits) + segment limit, bits 16-19

G: 1
D/B: 1
L: 0
AVL: 0

El segmento de datos es muy similar:

    db 10010010b

P: 1
DPL: 00
S: 1
Type(4bits):
    Code: 0
    Conforming: 0
    R/W: 1
    Accessed: 0

NOTA: Code: 0. No permitimos la ejecución de código ya que se trata de un segmento de datos

    db 11001111b

G: 1
D/B: 1
L: 0
AVL: 0

Calculamos el tamaño y la dirección de inicio de la tabla GDT para conformar el descriptor de la tabla, estos datos serán necesarios para que la CPU cargue la GDT mediante la instrucción lgdt.

; GDT descriptor
gdt_descriptor:
    dw gdt_end - gdt_start - 1 ; gdt size (16 bit), always one less of its true size
    dd gdt_start ; gdt start address (32 bit)

Gracias al descriptor de la tabla GDT la CPU conocerá la dirección de memoria donde empieza la tabla, debido a esto todas las referencias a un descriptor de segmento de la tabla GDT se indican a partir de la dirección de inicio de dicha tabla, por ese motivo para generar las constantes(equ) con la dirección de memoria del descriptor de segmento de código y datos tendremos que restarles la dirección donde empieza la GDT.

CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start

Hasta ahora para imprimir por pantalla un texto simplemente asignábamos el valor correcto en el registro al y llamábamos a la interrupción 0x10, pero en modo 32 bits todo esto cambia, para empezar la pantalla puede tener distintas resoluciones y dos modos, modo texto y modo gráfico, lo que se muestra por pantalla es una representación visual de lo que hay en una cierta posición de memoria por lo tanto para manipular la salida por la pantalla debemos manipular un rango de memoria en concreto, los dispositivos que funcionan de este modo como las tarjetas gráficas se les llama memory-mapped hardware.

Cuando una computadora arranca lo hace en un modo de video muy simple VGA: Video Graphics Array en modo texto a color con unas dimensiones de carácter de 80x25, en modo texto el programador no necesita renderizar pixels individuales para describir un carácter, la fuente ya fué definida en la memoria interna de la tarjeta gráfica. Cada carácter de la pantalla es representado por dos bytes en memoria, el primer byte es el código ASCII asociado al carácter y el segundo son los atributos del carácter, como el color, el color de fondo y si debe parpadear.

Por lo tanto si queremos mostrar un carácter en pantalla debemos setear un código ASCII y sus atributos en la posición de memoria correcta, la cual suele ser 0xb8000. Hay que tener en cuenta que a pesar de que la pantalla tenga filas y columnas como coordenadas la memoria de vídeo es secuencial, la fórmula para calcular la dirección de memoria asociada a cada coordenada es:

memoryPosition = 0xb8000 + 2 * (row * 80 + col)

Nuestro código de escritura en pantalla básicamente ejecutará estos pasos:

- Le pasamos la localización en memoria del string a imprimir a la función mediante el registros ebx
| -> Copiamos en al el contenido de ebx, carácter a imprimir
| - Seteamos ah a 0x0f, text blanco sobre fondo negro
| |- Comprobamos final de string
| |- Movemos ax a la posición de memoria de video
| |- Adelantamos en una byte la dirección de memoria desde donde estamos leyendo: add ebx, 1
| |- Adelantamos en dos bytes(char+attr) la dirección de memoria donde estamos escribiendo: add edx, 2
--|Loop
-> Fin

Veámoslo en código ensamblador:

vi 32bit-print.asm
[bits 32] ; using 32-bit protected mode

; constant definitions
VIDEO_MEMORY equ 0xb8000 ; memory address
;WHITE_ON_BLACK equ 0x0f ; the color byte for each character
WHITE_ON_BLACK equ 0x40 ; the color byte for each character

print_string_pm:
    pusha
    mov edx, VIDEO_MEMORY; edx now has video memory address

print_string_pm_loop:
    mov al, [ebx] ; memory address of char to print
    mov ah, WHITE_ON_BLACK ; attr to print

    cmp al, 0 ; check if end of string
    je print_string_pm_done

    mov [edx], ax ; store character(al) + attribute(ah) in video memory, in that step we have printed the char to screen
    add ebx, 1 ; next char to read from ebx
    add edx, 2 ; next char to write in video memory position

    jmp print_string_pm_loop

print_string_pm_done:
    popa
    ret

El problema de esta rutina es que siempre imprime la cadena en la parte superior izquiera de la pantalla y sobreescribirá los mensajes anteriores sin hacer scroll, esto se podría solventar pero no vamos a perder mas tiempo en ello ya que pronto estaremos cargando código escrito en un lenguaje de alto nivel que nos facilitará la tarea.

La última pieza del puzzle es indicarle a la CPU como pasar a modo protegido 32bits, para ello deshabilitaremos las interrupciones ya que estas están pensadas para que las atienda la BIOS que espera que todo funcione en modo 16 bits, cargaremos la GDT que hemos preparado y setearemos el registro cr0 de la CPU a través de un registro de propósito general, al hacer esto todo el código de nuestro bootloader es movido a las posiciones de memoria indicadas en el descriptor de segmento de código y los datos a las posiciones de memoria indicadas en el descriptor de segmento de datos.

En principio ya deberíamos de estar operando en modo protegido de 32bits pero el cambio puede ser peligroso por el pipelining de la CPU, esto quiere decir que la CPU puede hacer un fetch de las operaciones venideras mientras ejecuta la actual todo ello en un ciclo de reloj, este sistema se invalida con operaciones como jmp o call ya que en saltos o llamadas desconoce el destino y es imposible precargar las instrucciones futuras, si hay un salto a otro segmento de memoria el pipeline se descarta, nosotros vamos a utilizar precisamente esto para forzar a la CPU a descartar todas las operaciones pendientes en su pipeline.

En modo protegido de 32bits cuando indicamos una dirección SEGMENTO:OFFSET el segmento ya no indica el valor del segmento de memoria al que queremos acceder si no el descriptor de segmento de la GDT que contiene la información del segmento al que se desea acceder.

Para realizar el salto lejano y descartar los datos del pipeline ejecutaremos un jmp CODE_SEG:init_pm, salto a:

La dirección base que indique el descriptor de segmento de código
Con offset donde esté la etiqueta init_pm

Cuando pasamos a modo protegido 32 bits se debe setear el registro cs al descriptor de segmento de código de la tabla GDT y el resto de registros de datos al descriptor de segmento de datos. Al ejecutar la instrucción de salto jmp CODE_SEG:init_pm el registro cs se actualizará de forma automática pero los registros de datos tendremos que setearlos nosotros. Finalmente redefinimos nuestro stack.

vi 32bit-switch.asm
[bits 16]
switch_to_pm:
    cli ; disable interrupts
    lgdt [gdt_descriptor] ; load the GDT descriptor
    mov eax, cr0
    or eax, 0x1 ; set 32-bit mode bit in cr0
    mov cr0, eax
    jmp CODE_SEG:init_pm ; far jump by using a different segment

[bits 32]
init_pm: ; we are now using 32-bit instructions
    mov ax, DATA_SEG ; update the data segment registers
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

    mov ebp, 0x90000 ; update the stack right at the top of the free space
    mov esp, ebp

    call BEGIN_PM ; Call a well-known label with useful code

El programa principal sería este:

vi 32bit-main.asm
[org 0x7c00] ; bootloader offset
    mov bp, 0x9000 ; set the stack
    mov sp, bp

    mov bx, MSG_REAL_MODE
    call print ; This will be written after the BIOS messages

    call switch_to_pm
    jmp $ ; this will actually never be executed

%include "./boot_sect_print.asm"
%include "./32bit-gdt.asm"
%include "./32bit-print.asm"
%include "./32bit-switch.asm"

[bits 32]
BEGIN_PM: ; after the switch we will get here
    mov ebx, MSG_PROT_MODE
    call print_string_pm ; Note that this will be written at the top left corner
    jmp $

MSG_REAL_MODE db "Started in 16-bit real mode", 0
MSG_PROT_MODE db "Loaded 32-bit protected mode", 0

; bootsector
times 510-($-$$) db 0
dw 0xaa55

Generamos la imagen:

nasm -f bin 32bit-main.asm -o 32bit-main.bin

La cargamos en Qemu:

qemu-system-x86_64 32bit-main.bin

Si te ha gustado el artículo puedes invitarme a un redbull aquí.
Autor: kr0m -- 13/06/2020 05:43:03