Capítulo 7: Del modo real al modo protegido
Ha llegado el momento de abandonar los 16 bits. En la introducción a este bloque vimos que entre las funciones del bootloader se encuentra la de crear un entorno favorable para el kernel. Con esa frase, nos referimos principalmente a pasar a un entorno de 32 bits. Para eso, es indispensable pasar del modo real del procesador (que hemos estado usando todo este tiempo) al modo protegido.
En el capítulo 3 se explican las características del modo real del procesador. Por resumir: la mayoría de los valores están limitados a 65.535 y solo son accesibles los primeros 64 KB de la RAM, pero a cambio podemos acceder a los BIOS interrupts. Ahora que hemos pasado a la stage2 y tenemos más espacio, podemos ocuparnos de dejar el modo real y pasar al modo protegido.
Pasar a los 32 bits es una mejora en casi todos los sentidos: podremos utilizar código escrito en C para nuestro bootloader, podremos acceder hasta los primeros 4 GB de la RAM... sin embargo, hay un problema: no podremos usar los BIOS interrupts.
En este capítulo veremos cómo pasar del modo real al modo protegido, y en el siguiente veremos cómo podemos volver del modo protegido al modo real, en caso de que nos haga falta usar un BIOS interrupt.
Tabla de contenidos
Paso 1: Habilitar la linea A20
¡Jajaja! ¡Te mentí! Ese no es el primer paso. En realidad, el primer paso es deshabilitar los interrupts mediante la instrucción cli. Hacemos esto porque es esencial que ningún evento externo interrumpa el paso de un modo del procesador a otro. Si el proceso se ve interrumpido, las cosas pueden acabar muy mal.
Pero bueno, una vez hemos hecho eso tenemos que habilitar la línea A20. ¿Y qué es eso? Bueno, vamos a intentar explicarlo sin meternos a demasiadas complicaciones relacionadas con el hardware. Mi principal fuente va a ser el artículo de Wikipedia, que sintentiza muy bien toda la información, pero aún así vas a tener que tener paciencia.
¿Qué es la línea A20?
La principal característica de un procesador de 16 bits es que solo puede acceder a direcciones de memoria que ocupen 16 bits o menos. Es decir, solo puede acceder a los primeros 64 KB de cualquier memoria. Esta limitación supuso un problema para Intel, que desarrolló un sistema para que sus procesadores pudieran acceder a todo el primer MB de cualquier memoria. Es decir, que pudieran acceder a direcciones de hasta 20 bits.
Así es como nació la segmentación de memoria, ese sistema que hemos estado usando, por el cual las direcciones en la RAM se escriben siguiendo el esquema SEGMENTO:OFFSET. Esto se contrapone a la memoria lineal, en la cual tan solo escribes el OFFSET para acceder a cualquier dirección.
La dirección del código que está siendo procesado en el momento actual se almacena en una memoria especial, que consta de una línea eléctrica para cada bit. Por ejemplo, si la dirección de memoria actual es 00110101 y tenemos un procesador de 8 bits, las lineas eléctricas estarán encendidas o apagadas según su bit, de esta forma:
Estas lineas, vistas de izquierda a derecha, se llamarían A7, A6, A5, A4, A3, A2, A1 y A0. Los procesadores de 20 bits que desarrolló Intel tenían líneas de la 0 a la 19. Cuando se añadieron nuevas lineas para acceder a más memoria, IBM decidió introducir una puerta lógica llamada Gate-A20 que permitía desactivar la línea 20, y así emular el funcionamiento de los procesadores anteriores para mantener la compatibilidad con programas antiguos.
En los ordenadores de la arquitectura x86 (como el nuestro) la línea A20 viene desactivada por defecto. ¿Y cómo la activamos? Pues... interactuando con el controlador de teclado, porque en un principio se vinculó la Gate-A20 a ese controlador.
Preparación previa
Antes de nada, un recordatorio: estamos en la stage2 (en concreto, en el archivo src/stage2/entry.asm), y el único código que contiene actualmente sirve para poner un mensaje en la pantalla que hemos llamado msg_boot, cuyo valor es "Hola".
Vamos a preparar nuestra stage2 para hacer cosas más avanzadas. Para empezar, volveremos a asegurarnos de que todos los segmentos tienen el valor esperado (en este caso, 0), y también cambiaremos el nombre de nuestra función print a rmode_print y vamos a ponerle la directiva [bits 16] para dejar claro que solo funciona en modo real.
El código de nuestro archivo entry.asm debería parecerse a este:
bits 16
; SECCIÓN DEL EJECUTABLE: .entry
section .entry
; Variables importadas de linker.ld
extern __bss_start
extern __end
; Entry point (exportado para linker.ld)
global entry
entry:
; Poner mensaje de bienvenida a la 'stage2'
mov si, msg_boot
call rmode_print
; Copiar el número guardado en DL a una variable propia.
mov [boot_drive], dl
; Configuramos el stack, partiendo de que ds = 0.
mov ax, ds
mov ss, ax
mov sp, 0xFFF0
mov bp, sp
; El código seguirá por aquí...
; Una vez acabe el código, detener el sistema
jmp halt
; REAL MODE PRINT
rmode_print:
[bits 16]
mov ah, 0x0E
mov bl, 0
rmode_print_loop:
lodsb
cmp al, 0
jz rmode_print_ret
int 0x10
jmp rmode_print_loop
rmode_print_ret:
ret
; DETENER SISTEMA
halt:
hlt
jmp halt
; VARIABLES
msg_boot:
db "Hola", 0x0A, 0x0D, 0
boot_drive:
db 0
Una vez realizados estos cambios, podemos expandir el código de nuestra stage2 de forma segura. Siguiendo los pasos ya explicados, vamos a desactivar los interrupts y crear una función en la que activaremos la línea A20. El siguiente código se debe insertar donde se encuentra la anotación "El código seguirá por aquí...":
cli
call a20_enable
Activar (ahora sí) la línea A20
En esta nueva función tendremos que seguir los siguientes pasos:
- Desactivar el teclado
- Mandar un comando para leer el estado del teclado
- Guardar el valor recibido en el stack
- Mandar un comando para escribir el estado del teclado
- Copiar el estado anterior, aunque con el segundo bit encendido. Ese segundo bit es el que determina si la línea A20 está activada o no.
- Guardar los cambios.
- Volver a activar el teclado.
A todos estos pasos, se les añade el hecho de que cuando queremos escribir o leer a un controlador, tenemos que esperar a que dicho controlador responda. Por ello, también tendremos que crear son los métodos a20_waitinput y a20_waitoutput.
¿Y cómo mandamos comandos y leemos respuestas del controlador de teclado? Pues para eso tendremos que comunicarnos con los puertos de comandos y de datos del controlador de teclado, usando las instrucciones in y out. Para una mayor legibilidad, vamos a apuntar las IDs de cada puerto en nuestro programa usando la directiva equ, tal que así:
keyboard_port_data equ 0x60
keyboard_port_cmd equ 0x64
Los comandos que vamos a enviar también tienen IDs numéricas. De nuevo, las apuntamos en la parte del programa que mejor nos venga, usando la directiva equ:
keyboard_cmd_disable equ 0xAD
keyboard_cmd_enable equ 0xAE
keyboard_cmd_readoutput equ 0xD0
keyboard_cmd_writeoutput equ 0xD1
A continuación, definimos las funciones que se encargarán de esperar a que el controlador de teclado nos permita escribir datos o nos devuelva los que le hemos solicitado. Esto lo haremos comunicándonos en bucle con keyboard_port_cmd hasta que el registro al refleje los cambios esperados:
a20_waitinput:
[bits 16]
; Recibir datos de 'keyboard_cmd_port' hacia 'al'
in al, keyboard_port_cmd
; Esperar hasta que su segundo bit (input buffer) sea 0.
test al, 2
jnz a20_waitinput
ret
a20_waitoutput:
[bits 16]
; Recibir datos de 'keyboard_cmd_port' hacia 'al'
in al, keyboard_port_cmd
; Esperar hasta que su primer bit (output buffer) sea 1.
test al, 1
jz a20_waitoutput
ret
Ahora que ya tenemos todo lo necesario, vamos a traducir los pasos que hemos escrito antes a Assembly para crear nuestro método a20_enable. En todos los casos, esperaremos a que el controlador esté disponible, escribiremos o leeremos los datos necesarios, y pasaremos al siguiente paso:
a20_enable:
[bits 16]
; 1. Desactivar el teclado
call a20_waitinput
mov al, keyboard_cmd_disable
out keyboard_port_cmd, al
; 2. Mandar cmd para leer estado del teclado a 'keyboard_port_data'.
call a20_waitinput
mov al, keyboard_cmd_readoutput
out keyboard_port_cmd, al
; 3. Leer 'keyboard_port_data' hacia 'al' y guardarlo en el stack.
call a20_waitoutput
in al, keyboard_port_data
push eax
; 4. Mandar cmd para escribir el estado del teclado en 'keyboard_port_data'
call a20_waitinput
mov al, keyboard_cmd_writeoutput
out keyboard_port_cmd, al
; 5. Recuperar estado del teclado (guardado en el stack) y encender
; su segundo bit, que indica si la línea A20 está activada o no.
call a20_waitinput
pop eax
or al, 2
; 6. Escribir el estado modificado a 'keyboard_port_data'
out keyboard_port_data, al
; 7. Volver a activar el teclado.
call a20_waitinput
mov al, keyboard_cmd_enable
out keyboard_port_cmd, al
call a20_waitinput
ret
Paso 2: Cargar la Global Descriptor Table (GDT)
Lo siguiente que vamos a poner en nuestra funcion principal (entry), después de haber llamado a a20_enable y haber vuelto, es esta línea:
call gdt_load
El siguiente método que escribiremos servirá para cargar la GDT o Global Descriptor Table. De nuevo, nos encontramos con otro concepto que no hemos tocado en ningún punto del manual... déjame explicarte antes de seguir.
¿Qué es la GDT?
Usando la terminología de Intel, un segmento es una sección de la memoria que tiene unas propiedades concretas (p. ej.: si solo ciertos procesos pueden acceder a dicho segmento, o si dentro del segmento se almacenan datos o código ejecutable).
La GDT define los segmentos de memoria que usaremos en nuestro bootloader y sistema operativo. Cada entrada de la tabla ocupa 8 bytes y puede contener un descriptor de segmento, un Task State Segment, una Local Descriptor Table (LDT) o una call gate. Por ahora, a nosotros solo nos interesan los descriptores de segmento (o segment descriptors). Su estructura es la siguiente:
0x3F | 0x3E | 0x3D | 0x3C | 0x3B | 0x3A | 0x39 | 0x38 | 0x37 | 0x36 | 0x35 | 0x34 | 0x33 | 0x32 | 0x31 | 0x30 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
base | flags | limit | |||||||||||||
0x2F | 0x2E | 0x2D | 0x2C | 0x2B | 0x2A | 0x29 | 0x28 | 0x27 | 0x26 | 0x25 | 0x24 | 0x23 | 0x22 | 0x21 | 0x20 |
access byte | base | ||||||||||||||
0x1F | 0x1E | 0x1D | 0x1C | 0x1B | 0x1A | 0x19 | 0x18 | 0x17 | 0x16 | 0x15 | 0x14 | 0x13 | 0x12 | 0x11 | 0x10 |
base | |||||||||||||||
0xF | 0xE | 0xD | 0xC | 0xB | 0xA | 0x9 | 0x8 | 0x7 | 0x6 | 0x5 | 0x4 | 0x3 | 0x2 | 0x1 | 0x0 |
limit |
Nota importante: algunos valores están desperdigados por la tabla, por lo que conviene pararse bien a mirar su estructura antes de experimentar con ella. Por dar un ejemplo más claro, en la tabla mostrada a continuación:
0x3F | 0x3E | 0x3D | 0x3C | 0x3B | 0x3A | 0x39 | 0x38 | 0x37 | 0x36 | 0x35 | 0x34 | 0x33 | 0x32 | 0x31 | 0x30 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 0 | 1 |
0x2F | 0x2E | 0x2D | 0x2C | 0x2B | 0x2A | 0x29 | 0x28 | 0x27 | 0x26 | 0x25 | 0x24 | 0x23 | 0x22 | 0x21 | 0x20 |
0 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
0x1F | 0x1E | 0x1D | 0x1C | 0x1B | 0x1A | 0x19 | 0x18 | 0x17 | 0x16 | 0x15 | 0x14 | 0x13 | 0x12 | 0x11 | 0x10 |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
0xF | 0xE | 0xD | 0xC | 0xB | 0xA | 0x9 | 0x8 | 0x7 | 0x6 | 0x5 | 0x4 | 0x3 | 0x2 | 0x1 | 0x0 |
1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
Las partes del descriptor serían las siguientes:
- base = 00000000 00000000 00000000 00000111
- limit = 11111111 11111111 1011
- access byte = 01100010
- flags = 1100
La variable base define el byte en el que empieza un segmento, y limit define su tamaño en bytes. Las demás características de un segmento se definen en los bits del access byte y las flags.
El access byte define la siguiente información (siguiendo el esquema de la OS Dev Wiki):
0x7 | 0x6 | 0x5 | 0x4 | 0x3 | 0x2 | 0x1 | 0x0 |
---|---|---|---|---|---|---|---|
P
Present bit
Indica si el segmento es válido (1) o no (0). |
DPL
Descriptor Privilege Level bit
Nivel de privilegios del segmento, del 0 (kernel) al 3 (programas de usuario). |
S
Descriptor Type bit
Define si es un segmento de sistema (0) o un segmento de código/datos (1). |
E
Executable bit
Define si es un segmento de datos (0) o de código ejecutable (1). |
DC
Direction/Conforming bit
En segmentos de código: define si el código puede ejecutarse solo desde el nivel definido en el DPL (0) o desde cualquiera igual o inferior (1). En segmentos de datos: define si el segmento crece normal (0) o al revés (1). |
RW
Readable/Writable bit
En segmentos de código: define si el segmento se puede leer (1) o no (0). La escritura nunca es posible. En segmentos de datos: define si en el segmento se puede escribir (1) o no (0). La lectura siempre es posible. |
A
Accessed bit
La CPU le asigna el valor 1 al acceder a él. Es recomendable asignarle manualmente un 1 desde el principio, porque si la CPU intenta modificar este bit en segmentos de solo lectura, provocará una violación de segmento. |
Puedes pasar el ratón por encima de la tabla para ver una descripción más detallada de cada elemento.
Las flags disponibles son las siguientes:
0x3 | 0x2 | 0x1 | 0x0 |
---|---|---|---|
G
Granularity flag
Indica si el tamaño de segmento definido en limit se expresa en unidades de 1 byte (0 o de 4 KB (1). |
DB
Size flag
Define si el segmento es de 16 bits (0) o de 32 bits (1). |
L
Long-mode code flag
En segmentos de código: define si el segmento contiene instrucciones de 64 bits (1) o no (0). La size flag (DB) debe ser 0 si esta está activada. En cualquier otro caso: debe dejarse desactivada (0). |
Reservado |
Puedes pasar el ratón por encima de la tabla para ver una descripción más detallada de cada elemento.
Cómo escribir nuestra GDT
Teniendo esto en cuenta, ¡ya podemos definir nuestra propia GDT! Por ahora, crearemos una tabla muy simple, algo insuficiente para un sistema de uso general, como esta:
gdt_start:
; Primera entrada: debe estar vacía
dq 0
; Todas las entradas van del byte 0 al máximo en su caso.
; Segunda entrada: 32-bit CODE segment (0x08)
dw 0xFFFF
dw 0
db 0
db 10011010b ; Access: +P, DPL=0. +S, +E, -DC, +RW, -A
db 11001111b ; Flags: +G, +DB, -L
db 0
; Tercera entrada: 32-bit DATA segment (0x10)
dw 0xFFFF
dw 0
db 0
db 10010010b ; Access: +P, DPL=0. +S, -E, -DC, +RW, -A
db 11001111b ; Flags: +G, +DB, -L
db 0
; Cuarta entrada: 16-bit CODE segment (0x18)
dw 0xFFFF
dw 0
db 0
db 10011010b ; Access: +P, DPL=0. +S, +E, -DC, +RW, -A
db 00001111b ; Flags: -G, -DB, -L
db 0
; Quinta entrada: 16-bit DATA segment (0x20)
dw 0xFFFF
dw 0
db 0
db 10010010b ; Access: +P, DPL=0. +S, -E, -DC, +RW, -A
db 00001111b ; Flags: -G, -DB, -L
db 0
Esta tabla describe 2 entradas de 16 bits y 2 de 32 bits. En ambos casos, la primera es de código y la segunda de datos. Ahora, definiremos el GDT descriptor, que contiene la información necesaria para cargar la GDT.
Todo el espacio del disco es accesible desde todos los segmentos. Hacemos esto para simplificar las cosas, aunque dependiendo de nuestra organización posterior, puede ser que nos acabe interesando cambiar los límites de los segmentos.
Definir el GDT descriptor y cargar la GDT
Por suerte, el GDT descriptor es mucho más simple: se trata de una estructura de 6 bytes. Los 4 primeros definen la base de la tabla (es decir, el byte en el que empieza), y los 2 siguientes definen el tamaño de la tabla en bytes.
Si colocamos el descriptor justo debajo de nuestra GDT, es tan fácil como hacer esto:
gdt_desc:
dw gdt_desc - gdt_start - 1 ; Tamaño (2 bytes)
dd gdt_start ; Base (4 bytes)
Con todo esto ya declarado, solo nos queda completar nuestra función gdt_load, en la que cargaremos nuestra tabla usando la instrucción lgdt, y, como argumento, incluiremos un puntero hacia nuestro GDT descriptor.
gdt_load:
[bits 16]
lgdt [gdt_desc]
ret
Paso 3: Activar la PE flag
La PE flag (Protected Enable Flag) es una de las variables que se guardan en el primer registro de control, llamado cr0. Ocupa tan solo un bit (el más bajo de todo el registro), e indica al procesador si estamos en modo protegido (1) o no (0).
Este paso es muy simple. Tenemos que:
- Leer los contenidos del registro cr0 a un registro de 32 bits de uso arbitrario (como eax).
- Activar el último bit del registro escogido.
- Sobreescribir el registro cr0 con los contenidos del registro escogido.
Bueno, así dicho parece más difícil de lo que es. Vamos a hacerlo directamente en Assembly:
mov eax, cr0
or al, 1
mov cr0, eax
¡Y ya está! Después de la explicación de la GDT esto se debe sentir como un soplo de aire fresco.
Paso 4: Saltar a un segmento de código de 32 bits
Ahora que tenemos nuestra GDT cargada, tenemos que tener cuidado para no producir violaciones de segmento (es decir, acceder a una parte de la memoria no disponible, o intentar ejecutar código desde desde un segmento de datos). Es importante tener claro qué segmentos tenemos declarados en nuestra GDT y qué propiedades tiene cada uno.
A nosotros nos interesa ejecutar código de 32 bits, y para ello tendremos que saltar a un segmento de código de 32 bits. El único que tenemos declarado está en el offset 0x08 de nuestra GDT. Así que para ejecutar un far jump hacia ese segmento tendríamos que ejecutar la siguiente instrucción:
jmp dword 0x08:pmode32
; Nota: el nombre 'pmode32' es arbitrario, podemos llamar a la siguiente sección como queramos.
A partir de aquí, el modo del procesador cambiará a modo protegido de 32 bits, y saltaremos hacia el símbolo pmode32 de nuestro código, que declararemos así:
pmode32:
[bits 32]
; nuestro código...
Paso 5: Prepararnos para el modo protegido
Hemos cambiado el code segment (cs) al segmento 0x08, pero el segmento de datos (ds) y el del stack (ss) no los hemos actualizado. En nuestra GDT, el segmento de datos de 32 bits está en el offset 0x10, así que para evitar fallos nos conviene preparar los segmentos tal que así:
; En la sección 'pmode32'
mov ax, 0x10
mov ds, ax
mov ss, ax
Otra cosa que seguramente nos de problemas es que tenemos varios datos guardados sin inicializar en la sección .bss de nuestro proceso. Al cambiar de 16 bits a 32 bits, conviene hacer limpia para evitar lecturas erróneas. Para ello, vamos a pasar por cada byte de la sección y dejarlo en 0.
Para hacer esto, utilizaremos la instrucción stosb, que copia en el byte al que apunte edi el contenido de al y suma 1 al valor de edi. También usaremos la instrucción rep, que repite una instrucción el número de veces indicado en ecx... Es más claro verlo en código.
mov edi, __bss_start ; edi = comienzo de BSS
mov ecx, __end ; ecx = final del proceso
sub ecx, edi ; ecx = tamaño de BSS
mov al, 0 ; al = 0
cld ; Direction flag = adelante
rep stosb ; memcpy(edi, al, ecx);
A partir de aquí, ya no deberíamos tener problemas de memoria ni violaciones de segmento si hacemos las cosas bien. Como ejemplo, en la siguiente sección vamos a poner en el terminal un mensaje desde el modo protegido.
Extra: poner un mensaje desde el modo protegido
¿Recuerdas cómo poníamos mensajes cuando estábamos en modo real? Usábamos el INT 10h/AH=0Eh para poner cada caracter en el terminal. Ahora, en el modo protegido, no podemos acceder a la parte de la memoria en la que está escrito el código de los BIOS interrupts, así que... no podemos hacer eso. Tenemos que implementar la función nosotros mismos.
El buffer VGA
Nuestro procesador se encuentra ahora mismo en un modo de vídeo que se basa en un buffer VGA. Es decir, existe una sección de la memoria, que abarca desde el byte 0xA0000 hasta el byte 0xBFFFF en la que se define cada uno de los píxeles visibles en la pantalla. Si escribimos en esta región de la memoria, podemos modificar los contenidos de la pantalla.
Dentro de esa región de la memoria existen varias partes distintas. A nosotros nos interesa la sección que abarca desde el byte 0xB8000 hasta el byte 0xB8FA0. Es un área de 4000 bytes que en la que está escrito cada caracter de la pantalla, su color de fondo y su color de frente, formando una tabla de 80 carácteres por 25 líneas.
Para explicarlo, vamos a imaginar que la cuadrícula es de 8 caracteres por 4 lineas, formando esta tabla:
Cada caracter ocupa 2 bytes, que se distribuyen tal que así:
0xF | 0xE | 0xD | 0xC | 0xB | 0xA | 0x9 | 0x8 | 0x7 | 0x6 | 0x5 | 0x4 | 0x3 | 0x2 | 0x1 | 0x0 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
caracter | fondo | frente |
De izquierda a derecha, el primer byte almacena la ID en código ASCII del caracter que queramos mostrar (0x41 para la A, 0x42 para la B, etc.). El segundo byte almacena en sus primeros 4 bits la ID del color de fondo, y en sus últimos 4 bits la ID del color de frente. La tabla a continuación muestra la ID de cada color:
0x0 | 0x1 | 0x2 | 0x3 | 0x4 | 0x5 | 0x6 | 0x7 | 0x8 | 0x9 | 0xA | 0xB | 0xC | 0xD | 0xE | 0xF |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Por lo tanto, si definimos una estructura de 2 bytes con los siguientes datos:
0x1 | 0x0 | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0x4D | 0x1F |
Estaríamos definiendo la letra M mayúscula con un color de fondo azul y un color de frente blanco, como esta: M.
Debido a que los datos se deben guardar siguiendo la convención little endian, los bytes separados se escribirían 0x4D, 0x1F, pero al escribirlos como una word (2 bytes), el orden se invierte: 0x1F4D.
La dirección 0xB8000 de la memoria guarda los datos de la primera celda de nuestra tabla, por lo que si escribimos nuestra letra (0x1F4D) a esa dirección, nuestra pantalla de 8 caracteres por 4 lineas mostrará esto:
M | |||||||
Si avanzamos 2 bytes y escribimos en la dirección 0xB8002 la letra 0x1F6F (0x6F para la o minúscula, 0x1 para el color azul de fondo y 0xF para el color blanco de fondo), veremos esto:
M | o | ||||||
Si has seguido la explicación, deberías tener las herramientas teóricas para imaginarte ya cómo se hace esto, que es nuestra idea:
M | o | d | o | p | r | o | |
t | e | g | i | d | o | ||
¿Cómo trasladamos eso a nuestro código?
Para que el código sea legible, lo primero que vamos a hacer es definir una constante que contenga la dirección del buffer VGA. Yo la he llamado memory_screen_buffer:
memory_screen_buffer equ 0xB8000
También vamos a definir nuestra nueva cadena de texto en la parte del archivo en la que tengamos nuestras variables:
msg_protected:
db "Modo protegido", 0
Dado que ahora no tenemos acceso al BIOS interrupt que usábamos antes, los caracteres 0x0D (retorno de carro) y 0x0A (salto de linea), que hemos incluido en todas nuestras cadenas de texto hasta ahora, no realizarán sus funciones correctamente. Llegado el momento, implementaremos los cambios necesarios para que vuelvan a ser funcionales, pero por ahora, lo mejor es no incluirlos en nuestra variable.
Ahora vamos a redactar la función en sí. Nuestra implementación nos permitirá mostrar cualquier cadena de texto, en cualquier posición de la pantalla, en cualquier color. Recibiremos esos 3 datos en los siguientes registros:
-
edi: puntero hacia la dirección de nuestro buffer VGA.
Ejemplo: 0xB8000 para el principio del buffer, o 0xB8002 para el segundo caracter.
Aviso: Los números impares provocarán comportamientos inesperados.
-
esi: puntero hacia nuestra cadena de texto.
Ejemplo: msg_boot para nuestro mensaje de bienvenida.
-
bl: colores de frente y de fondo para el texto.
Ejemplo: 0x20 para texto verde sobre fondo negro.
Es interesante destacar el uso de algunos registros de 32 bits (edi y esi). Esto se debe a que en el modo de 32 bits, los punteros ocupan 32 bits. Concretamente, en este caso el uso de registros de 16 bits no debería ocasionar problemas, pero conviene adaptarse. Además, estos registros no son arbitrarios. El uso de otros registros, aunque sean de 32 bits, puede ocasionar problemas.
También usamos un registro de un solo byte (bl) para el color, debido a que no hace falta más espacio. Este registro sí es arbitrario. Puede cambiarse, por ejemplo, por cl o dl.
A partir de aquí, el código es parecido al de la función rmode_print:
pmode_print:
[bits 32] ; Emitir código de 32 bits
pmode_print_loop:
lodsb ; Copiar 'edi' a 'al' e incrementar 'al'
or al, al ; Comprobar si 'al' es igual a 0
jz pmode_print_ret ; Si lo es, terminar la función
mov [edi], al ; Copiar 'al' hacia nuestro buffer
inc edi ; Apuntar al siguiente byte del buffer
mov [edi], bl ; Copiar el código de color hacia buffer
inc edi ; Incrementar 'edi' para apuntar hacia el siguiente caracter
call pmode_print_loop ; Comprobar el siguiente caracter
pmode_print_ret:
ret ; Terminar la función
Ahora, si quisiéramos imprimir el texto Modo protegido, en la esquina superior izquierda de la pantalla, de color blanco sobre azul, solo tendríamos que invocar la función tal que así:
mov edi, memory_screen_buffer
mov esi, msg_protected
mov bl, 0x1F
call pmode_print
Por último, asegúrate de que, después de estas lineas, el código salta a la función halt. Se asume que durante todo el capítulo has ido arrastrando la linea jmp halt al final del código que hemos ido añadiendo. Si no, el programa seguirá ejecutándose linea por linea y dará error.
Resumen
En este episodio hemos aprendido a hacer dos cosas: pasar al modo protegido y escribir al buffer VGA (o memoria de vídeo). Esto nos deja muy cerca de nuestro primer archivo escrito en C. Tan solo nos queda un paso: programar el punto de entrada a C y aprender a saltar a ese punto.
Antes de llegar ahí, aprenderemos también cómo pasar del modo protegido al modo real de nuevo. Esto será necesario cuando queramos usar cualquier BIOS interrupt, lo cual resulta necesario en algunos casos. También dedicaremos un apartado a organizar nuestro código correctamente para poder extender el proyecto sin preocuparnos de rehacer build.sh.
Ejemplo descargable
Como siempre que añadimos código al proyecto, puedes descargar un ejemplo de sistema operativo con lo que llevamos escrito hasta ahora (capitulo-7.tar.gz).