Capítulo 3: Darle vida a nuestro bootloader


¿No te ha parecido suficiente con la tupa de antes? Pues ahora vas a ver. En este capítulo vamos a coger el bootloader que programamos en el capítulo anterior y preprarlo para imprimir texto en pantalla y formatear nuestro disco con el sistema de archivos FAT12. ¿Listo? Pues vamos allá.

Tabla de contenidos


  1. Imprimir caracteres usando la BIOS
  2. Unir los caracteres en cadenas de texto
  3. Imprimir (y declarar) una cadena de texto
  4. Definir el sistema de archivos FAT12
  5. Formatear el disco
  6. Resumen

Imprimir caracteres usando la BIOS


Obviamente, no es lo mismo hacer esto desde un sistema operativo que desde un entorno freestanding (es decir, sin bibliotecas ni sistemas operativos a los que llamar). Déjame explicarte un segundo en qué modo del procesador nos encontramos, porque es importante para imprimir texto en pantalla.

Al iniciar el ordenador, el procesador se encuentra en modo real. Este es un modo de operación que emula el procesador Intel 8086. La CPU se inicia así para ser compatible con los programas pensados para dicho procesador, por tanto, nuestra funcionalidad está limitada a la que tenía este procesador de 1978, que es... bastante poca. Solo puedes operar con números de 16 bits o menos (menores de 65.535) y solo los primeros 64 KB de la RAM son accesibles.

Si vienes de capítulos anteriores, habrás notado que los registros que hemos estado usando hasta ahora son de 32 bits: eax, ebx, ecx, etc. Estos registros se pueden seguir usando, pero son menos útiles, porque los valores con los que trabajaremos (como los valores del stack o las direcciones de la memoria), ahora serán de 16 bits, como lo eran en el procesador Intel 8086. Por todo esto, acostúmbrate a usar los registros de 16 bits, como ax, bx y cx.

Una cosa que sí puedes hacer desde el real mode y no desde el resto de modos es llamar a las funciones de la BIOS, de las que ya hablamos en el capítulo anterior. Esto nos ahorra una cantidad brutal de horas de estudiar el hardware y comunicarnos directamente con él. Uno de esos BIOS interrupts nos permite poner texto en pantalla.

Imprimir un solo caracter

Bueno. Sin más preámbulo, vamos a usar el interrupt 0x10 para imprimir texto en pantalla. Para imprimir un solo caracter utilizaremos el siguiente código:

mov ah, 0x0E    ; ah = 0x0E (necesario para el interrupt).
mov al, 'a'     ; al = Caracter para imprimir.
int 10h         ; Llamar al interrupt.

Es importante fijarse en que la instrucción int 0x10 (o int 10h, que es otra manera de indicar que el número está en hexadecimal) no hace todo el trabajo sola. Necesita que ah contenga el valor 0x0E para entender que quieres imprimir un caracter en la pantalla. Si en el registro hay otro valor distinto, es probable que ejecute una función distinta (o ninguna).

Por eso, el nombre completo de esta función sería INT 10/AH=0Eh. La nomenclatura indica que usamos la instrucción int 10h (sinónimo de int 0x10) mientras en el registro ah está guardado el valor 0Eh (sinónimo de 0x0E).

Unir los caracteres en cadenas de texto


Las cadenas de texto o strings no son más que varios caracteres guardados en lugares consecutivos de la memoria. Por razones que veremos en este apartado, también deben terminar con un byte con el valor de 0. Por todo esto, si declaramos una string en Assembly, estamos definiendo una serie o array de bytes (con la instrucción db), tal que así:

string_hola:
    db "Hola", 0

Si en cualquier parte de nuestro código ponemos ahora string_hola, cuando lo compilemos se reescribirá con la dirección que tenga esa string en la memoria (que se suele escribir en hexadecimal). Por ejemplo, si dicha dirección es el byte 0x4 y escribimos:

mov ax, string_hola

Al compilar el código con nasm, la misma instrucción en el archivo .obj se verá más o menos así si desensamblamos el código máquina:

mov ax, 0x0004

El registro ax ahora tendrá el valor 0x0004. Es decir, la dirección de esa string en la memoria, y no la string en sí. ¿Y todos esos ceros? Bien por fijarte. El registro ax es de 16 bits (es decir, puede guardar números de 16 cifras en binario, que se traducen a 4 en hexadecimal). Para acceder a sus últimos 8 bits podemos usar el registro al, y también podemos acceder a sus primeros 8 bits con el registro ah. Eso es importante para aclarar un último matiz.

Existen varias formas de escribir direcciones de memoria. La más fácil (y la usada por cualquier sistema operativo moderno) es la memoria lineal, pero en el modo real del procesador (en el que estamos ahora), todavía no se puede hacer eso. Se accede a la memoria por segmentos y offsets. En memoria lineal, equivale a anotar la dirección (seg * 16) + offset, teniendo en cuenta que 16 es 0x10 en hexadecimal. Aquí tienes algunos ejemplos:

	Segmentada                        Lineal
	--                                --
	0x0001:0x0000 = 0x0010 + 0x0000 = 0x0010
	0x0002:0x0005 = 0x0020 + 0x0005 = 0x0025
	0x0000:0x0030 = 0x0000 + 0x0030 = 0x0030
			

Entonces volvemos a nuestra string. La tenemos alojada en la dirección lineal 0x4 de la memoria, tal que así:

0x4 0x5 0x6 0x7 0x8
'H' 'o' 'l' 'a' 0

Para anotar su dirección completa (segmento:offset) no basta con los 16 bits de un registro, se necesitan 32 bits, 16 bits para el segmento y otros 16 para el offset. Por eso, algunos registros se diseñaron específicamente para incluir segmentos en ellos. Los que nos interesan ahora mismo son cs (code segment), ds (data segment) y ss (stack segment).

Atención aquí. El code segment es el segmento en el que está el código que estamos ejecutando actualmente. Su registro, cs no se puede manipular arbitrariamente. Ya veremos cómo asegurarnos de que su valor es correcto. Los otros 2 sí se pueden mover arbitrariamente, y en este caso nos interesa que estén el segmento 0, porque así es más simple escribir las direcciones. Eso se hace así:

mov ax, 0     ; Los segmentos deben recibir valores desde un reg.
mov ds, ax    ; ds = ax (0).
mov ss, ax    ; ss = x (0).

Hecho esto, ya podemos acceder a nuestra string después de ejecutar la siguiente linea de código:

mov si, 0x0004

Ahora, si ponemos ds:si estaremos anotando la dirección 0x0000:0x0004... ¡la dirección de nuestra string! Ya podríamos pasar a imprimirla.

Una cosita solo. La función incluida en el siguiente apartado utiliza el stack para guardar y recoger valores de los registros. Si no sabes qué es el stack, te recomiendo leer el apartado que hay sobre el tema en el capítulo 1.

Imprimir (y declarar) una cadena de texto


Preparar los registros y el stack

A continuación, usaremos el stack por primera vez en el código de nuestro bootloader. Para ello, antes hay que inicializar el stack. Es decir, decirle a nuestro programa a qué dirección de la memoria van los datos que guardamos en el stack. Junto al segmento del stack (ss), también es importante definir los segmentos que utilizaremos (ds, es y cs). Todos tendrán, por ahora, el valor 0.

Es recomendable configurar el stack para que crezca a partir de la dirección 0x7C00, que es donde empieza nuestro programa. Recuerda que el stack crece hacia abajo. Así, todo lo que mandemos al stack se situará en la parte de la memoria inmediatamente anterior a nuestro código. Con esta configuración, el stack puede ocupar hasta 29,75 KB sin escribir sobre memoria usada.

Para hacer eso, escribiremos este código justo en el punto de entrada de nuestro bootloader:

mov ax, 0

; Data segment = 0
mov ds, ax

; Extra segment = 0
mov es, ax

; Inicio del stack = 0x0000:0x7C00
mov ss, ax
mov sp, 0x7C00

; Garantizar que el code segment es 0x0000
push es
push word start_checked
retf

start_checked:
    ; nuestro código

Conviene aclarar algunas partes del código:

La función en sí

Si llamamos a nuestra función print, una implementación correcta sería, más o menos, la siguiente:

print:
    mov ah, 0x0E       ; ah = 0x0E (necesario para el interrupt).
    push si            ; Copiar el valor de 'si' al stack.

print_loop:
    lodsb              ; al = siguiente byte en la direcc. 'ds:si'.
    cmp al, 0
    jz print_ret       ; Si al == 0, detener el bucle.

    int 0x10           ; Si no, imprimir 'al' en la pantalla.

    call print_loop    ; Volver a "print_loop".

print_ret:
    pop si             ; Restaurar el anterior valor de 'si'.
    ret                ; Volver de la función.

Aquí hemos usado varias instrucciones nuevas. Si tienes dudas, te las describo un poquito mejor aquí:

Esto imprimirá cualquier texto que le eches siempre y cuando acabe en un byte con el valor de 0. Ahora solo tienes que guardar en algún sitio la cadena de texto que quieras imprimir. Es muy recomendable poner nuestras variables por encima de la instrucción times 510-($-$$) db 0 que escribimos en el capítulo anterior.

La pongas donde la pongas, definela así:

msg_prueba:
    db "Mensaje de prueba", 0x0D, 0x0A, 0

Ese código define una sucesión de bytes cuyo primer valor está en una dirección a la que llamaremos msg_prueba. Esa cadena de bytes contiene el texto Mensaje de prueba, un retorno de carro, un salto de linea y un byte nulo (0) para terminar la cadena.

Es muy importante tener en cuenta que al poner msg_prueba estamos escribiendo en realidad la dirección de la 'M'. Si le vamos sumando uno, tendremos la dirección de la 'e', de la 'n', etc. Esto es exactamente lo que hace la instrucción lodsb: guarda en el registro al el byte localizado en la dirección a la que apunta ds:si y suma uno a la propia dirección. Por eso, si no terminamos con un 0, nuestra función seguirá leyendo una cantidad indefinida de bytes.

Si quisiéramos imprimir la string que hemos guardado en msg_prueba, podemos invocar a la función print usando el siguiente código:

mov si, msg_prueba
call print

Definir el sistema de archivos FAT12


Llegados a este punto, puede que pienses «bueno, ¿y para hacer algo útil con nuestro sistema cómo lo hacemos?»

Razón no te falta. Estamos imprimiendo textitos desde un sistema que no tiene ni archivos, ni procesos, ni es capaz siquiera de leer lo que llega desde el teclado o el ratón. Así que venga, vamos a empezar metiéndole a esto un sistema de archivos. En concreto, usaremos FAT12, que es un sistema muy simple en comparación con los actuales.

Headers de FAT12

El sistema de archivos espera que en el byte 0x7C00 (que es el primer byte de nuestro bootloader) se declaren ciertas propiedades del disco, como el máximo de archivos del directorio raíz. Este primer header ocupa 36 bytes y su estructura en el disco es esta:

0x0 0x1 0x2 0x3 0x4 0x5 0x6 0x7 0x8 0x9 0xA
jmp
Bloque de instrucciones muy corto usado para que nuestro código se salte el header. Normalmente se declara así:

jmp entry_point
nop
*La instrucción nop está diseñada para, literalmente, no hacer nada.
OEM
Nombre del fabricante (8 bytes, rellenado con espacios).
Ejemplo: GNU  ORG, aunque se suele usar MSWIN4.1 para tener una mayor compatibilidad.
0xB 0xC 0xD 0xE 0xF 0x10 0x11 0x12 0x13 0x14 0x15
b/sec
Número de bytes por sector del disco (normalmente, o 512 bytes o 4 KB)
s/cl
Número de sectores por cluster del disco (lo más simple es que cada cluster contenga solo un sector)
ressec
Número de sectores reservados para el sistema (mínimo 1 sector, en el que incluiremos este mismo header).
fats
Número de File Allocation Tables incluidas en el disco (desarrollaremos esto en el siguiente capítulo)
entries
Número de entradas incluidas en la carpeta raíz (desarrollaremos esto en el siguiente capítulo)
sectors
Número total de sectores en el disco. Depende de la geometría del disco que quieras usar.
Ejemplo: para un floppy 3½" DSHD 1.44 MB, serían 2880.
desc
Byte que describe el dispositivo de inicio. Depende de la geometría del disco que quieras usar.
Ejemplo: para un floppy 3½" DSHD 1.44 MB, sería 0xF0.
0x16 0x17 0x18 0x19 0x1A 0x1B 0x1C 0x1D 0x1E 0x1F 0x20
s/fat
Sectores que ocupa cada File Allocation Table (desarrollaremos esto en el siguiente capítulo)
s/track
Número de sectores por cilindro. Depende de la geometría del disco que quieras usar.
Ejemplo: para un floppy 3½" DSHD 1.44 MB, serían 18.
heads
Número total de cabezas en el disco. Depende de la geometría del disco que quieras usar.
Ejemplo: para un floppy 3½" DSHD 1.44 MB, serían 2.
hiddensec
Número de sectores ocultos en el disco (debe ser 0 en discos de una sola partición, como el nuestro)
larg...
Número total de sectores en el disco (únicamente si el número de sectores es mayor que 65.535). Depende de la geometría del disco que quieras usar.
Ejemplo: para cualquier disco con menos de 65.535 sectores, se debe usar 0, pero si el disco tiene 70.000 sectores, aquí pondremos 70000, y en sectors pondremos 0.
0x21 0x22 0x23
...largesec
Número total de sectores en el disco (únicamente si el número de sectores es mayor que 65.535). Depende de la geometría del disco que quieras usar.
Ejemplo: para cualquier disco con menos de 65.535 sectores, se debe usar 0, pero si el disco tiene 70.000 sectores, aquí pondremos 70000, y en sectors pondremos 0.

Puedes pasar el ratón por encima de la tabla para ver una descripción más detallada de cada elemento.

También se debe definir en los bytes inmediatamente siguientes el Extended BIOS Parameter Block, una estructura más breve (de 26 bytes) que contiene la siguiente información:

0x0 0x1 0x2 0x3 0x4 0x5 0x6 0x7 0x8 0x9 0xA
drive
Número de disco: los floppys abarcan del 0x00 al 0x7E, y los discos duros del 0x7F al 0xFE.
res
Byte reservado para el sistema, se debe dejar vacío.
sign
Firma de boot (0x29 en sistemas modernos)
volume_id
Número de serie del disco. Puedes poner aquí cualquier valor de 4 bytes.
volume_name...
Nombre del disco: valor arbitrario de 11 bytes, rellenado con espacios.
Ejemplo: NO NAME    
0xB 0xC 0xD 0xE 0xF 0x10 0x11 0x12 0x13 0x14 0x15
...volume_name
Nombre del disco: valor arbitrario de 11 bytes, rellenado con espacios.
Ejemplo: NO NAME    
system_name...
Nombre del sistema de archivos (8 bytes, rellenado con espacios).
Ejemplo: en nuestro caso, debemos poner FAT12   
0x16 0x17 0x18 0x19
...system_name
Nombre del sistema de archivos (8 bytes, rellenado con espacios).
Ejemplo: en nuestro caso, debemos poner FAT12   

Puedes pasar el ratón por encima de la tabla para ver una descripción más detallada de cada elemento.

Si te interesa saber más sobre la especificación, puedes irte directamente al documento de Microsoft o al artículo de Wikipedia sobre los sistemas de archivos FAT, que es extensísimo y muy detallado.

¿Qué valores le ponemos a todo esto? Eso depende de la geometría de nuestro disco, y para eso tenemos que tener claro qué tipo de disco queremos representar en nuestro archivo .img. Por suerte, ya hemos tomado algunas decisiones: nuestra imagen de disco ocupa 1440 KB. No es casualidad: la intención es emular un floppy de alta densidad de 3 pulgadas y media (3½" DSHD 1.44MB), que es un formato sobre el que existe mucha documentación.

Es probable que nunca en la vida hayas visto uno, por lo que en los capítulos sobre el bootloader te va a venir muy bien tener a mano una calculadora de geometría de disco. Si te interesa conocer algo más sobre este tipo de disco, te puede resultar interesante leer este artículo del blog El Rincón de Cabra.

Todas estas variables tenemos que definirlas al principio de nuestro bootloader, antes del código que ya tenemos hecho. Aquí tienes un header totalmente compatible con el tipo de disco que queremos emular. Está diseñado para mi sistema operativo, scratchOS, y está basado en el diseñado por nanobyte para su sistema operativo, pero siéntete libre de cambiarle el nombre:

; FAT HEADER
jmp short start
nop

                    bdb_oem: db 'MSWIN4.1'    ; 8 bytes
       bdb_bytes_per_sector: dw 512           ; 512 bytes/sector
    bdb_sectors_per_cluster: db 1
       bdb_reserved_sectors: dw 1
              bdb_fat_count: db 2
      bdb_dir_entries_count: dw 0xE0          ; 224 entradas en la carpeta raíz
          bdb_total_sectors: dw 2880          ; 2880 * 512 = 1.44MB
  bdb_media_descriptor_type: db 0xF0          ; 0xF0 = Floppy de 3.5"
        bdb_sectors_per_fat: dw 9             ; 9 sectores/FAT
      bdb_sectors_per_track: dw 18
                  bdb_heads: dw 2
         bdb_hidden_sectors: dd 0
     bdb_large_sector_count: dd 0

; EXTENDED BOOT RECORD
           ebr_drive_number: db 0                    ; 0x00: floppy, 0x80: hdd
                             db 0                    ; reservado
              ebr_signature: db 29h                  ; mejor que sea 0x29
              ebr_volume_id: db 12h, 34h, 56h, 78h
           ebr_volume_label: db 'SCRATCH  OS'
              ebr_system_id: db 'FAT12   '

Formatear el disco


Estos datos no nos valen para nada si el disco no está formateado con el sistema de archivos FAT12. Para hacer eso, tendremos que cambiar ligeramente nuestro sistema para construir el archivo boot.img. Hasta ahora, lo hemos hecho así:

nasm -f bin boot.obj boot.asm                 # Compilar boot.asm
dd if=/dev/zero of=boot.img bs=512 count=2880 # Crear imagen llena de ceros
dd if=boot.bin of=boot.img conv=notrunc       # Copiar boot.bin a la imagen

Nota: Si no recuerdas esto, puede que te ayude leer el capítulo anterior.

A partir de ahora, añadiremos el comando mkfs.fat a nuestro script. Este comando nos permitirá formatear nuestra imagen de disco con el sistema de archivos FAT12. Gracias a esto, podremos meter archivos en el disco antes incluso de tener nuestro sistema listo.

# Compilar
nasm -f bin boot.obj boot.asm

# Crear disco y formatearlo
dd if=/dev/zero of=boot.img bs=512 count=2880
mkfs.fat -F 12 -n "SCRATCH  OS" boot.img

# Escribir la parte 1 de nuestro bootloader en los primeros 512 bytes
dd if=boot.bin of=boot.img conv=notrunc

Ahora, podremos meter y sacar archivos del disco si lo montamos como una unidad externa usando cualquier explorador de archivos. También podremos meter archivos dentro utilizando el programa mtools, de GNU.

Como curiosidad, si quieres que la imagen de disco se monte con el nombre de tu sistema operativo (algo bastante recomendable), lo puedes hacer incluyendo la opción -n al comando mkfs.fat. El siguiente ejemplo está tomado del código de scratchOS:

mkfs.fat -F 12 -n "SCRATCH  OS" scratchOS.img

Recuerda: el nombre debe ocupar 11 bytes exactamente.

Resumen


En este capítulo, has aprendido a poner texto en pantalla y a formatear el disco con el sistema de archivos FAT12. Ya estás empezando a hacer uso de ese limitado entorno en el que te deja la BIOS tras su secuencia de inicio.

En el siguiente capítulo, aprenderemos a obtener información del disco, reiniciarlo y hacer algunas operaciones simples con él. También empezaremos a ver exactamente qué es FAT12 y cómo se organiza nuestro disco una vez formateado.