Capítulo 6: Leer la stage2 y saltar hacia ella
En este capítulo terminaremos la stage1 y cederemos el control del sistema a la stage2. De esta forma, ampliaremos nuestro límite de espacio de 512 bytes a 29,75 KiB. En el proceso pondrás a prueba tu nivel de programación en assembly y tu conocimiento sobre FAT12.
Este es un capítulo complicado. Tómate tu tiempo en cada una de las secciones y asegúrate de que estás cómodo con tus conocimientos antes de pasar a la siguiente.
Tabla de contenidos
Instalar un cross compiler
Hasta ahora tan solo hemos usado NASM para compilar nuestros programas, y no nos hemos tenido que preocupar de la plataforma para la que estamos programando nuestro sistema operativo, pero al crear la stage2 debemos tenerla en cuenta.
Existen muchos tipos de procesador, y muchos sistemas operativos. Lo más probable es que el código producido para un procesador concreto, o para un sistema concreto, no funcione en los demás. Por eso, existen distintas versiones de cada compilador dependiendo de la plataforma para la que queremos generar nuestro código.
Yo, por ejemplo, escribo este texto desde la plataforma x86_64-pc-linux-gnu. El compilador GCC que tengo instalado produce código para esa misma plataforma. Sin embargo, nosotros no estamos produciendo código para Linux, sino para nuestro propio sistema operativo.
Existen versiones de GCC que generan código para plataformas genéricas, como i686-elf o arm-none-eabi. En este manual, por mantenernos en linea con la OSDev Wiki, y por la cantidad de documentación que existe en Internet, nos quedaremos con la arquitectura i686-elf.
En los apartados a continuación, se entiende que tienes a tu disposición una versión de GCC para la arquitectura i686-elf. Puedes seguir los siguientes materiales para realizar la instalación:
- GCC Cross-compiler, en la OSDev Wiki: una guía muy detallada para realizar correctamente la instalación.
- Setting up an OS dev environment, building GCC toolchain, de nanobyte en YouTube: una guía en vídeo, por si no te apetece leer un artículo tan detallado.
Copiar archivos a nuestro disco con mtools
mtools es un programa desarrollado por GNU que permite manipular discos de MS-DOS sin montarlos al sistema. Es probable que lo sepas, pero habitualmente para poder manipular un disco en Linux se usa el siguiente comando:
mount disco.img carpeta
Este comando carga los contenidos de disco.img a la carpeta carpeta. A partir de ahí, las modificaciones que hagas en la carpeta se escribirán también en el disco, y, al terminar, se usa el comando umount carpeta para desmontar el disco. Resulta simple, pero puede ser molesto tener que usar este método para cada cambio que queremos hacer en el disco. Por eso, nosotros vamos a utilizar mtools.
A continuación, presento una lista de comandos incluidos en mtools que nos pueden resultar bastante útiles.
## CARPETAS
mdir -i disco.img # Muestra el directorio raíz de disco.img
mdir -i disco.img -/ # Muestra todas sus carpetas
mmd -i disco.img carpeta # Crea la carpeta "carpeta" en el disco
mrd -i disco.img carpeta # Elimina la carpeta "carpeta"
## ARCHIVOS
mcopy -i disco.img archivo "::copia" # Crea en el disco el archivo "copia" con el contenido del archivo "archivo", ubicado fuera del disco.
Existen muchos más comandos de mtools. Para poder verlos y usarlos por tu cuenta, descarga el paquete de mtools que esté disponible para tu distribución de Linux.
En este capítulo usaremos sobre todo el comando mcopy. Por ejemplo, si hemos creado el programa editor.exe y lo queremos trasladar a nuestro disco, podemos ejecutar el siguiente comando para copiarlo hacia la carpeta raíz.
mcopy -i disco.img editor.exe "::editor.exe"
Crear nuestra stage2
Vamos a programar (por fin) un código mínimo para nuestra stage2. En este proceso, es importante tener muy claro qué es exactamente lo que estamos haciendo, y cuál va a ser su posición final en nuestro bootloader.
Estamos programando un bootloader. Hemos definido su punto de entrada y algunas funciones importantes en stage1.bin. Ahora vamos a crear stage2.bin, el segundo ejecutable del bootloader. La stage1 está escrita en los primeros 512 bytes de nuestro disco, y se carga en la dirección 0x7C00 de la memoria antes de ser ejecutada. Por otra parte, la stage2 estará escrita en un archivo dentro de nuestro disco, y podemos elegir la dirección de la RAM en la que la cargaremos.
Yo he escogido la dirección 0x500 para cargar mi stage2, por varias razones:
- Es el primer byte de una región de memoria libre que ocupa 29,75 KiB. Eso ofrece bastante espacio para nuestro código. Como curiosidad, esta región se sitúa justo antes de nuestro boot sector (más información).
- Está en el primer segmento de la memoria, lo cual simplificará las cosas en el futuro cercano (al pasar de real mode a protected mode).
Otra dirección común es 0x7E00, que ofrece más espacio. En cualquier caso, antes de elegir, asegúrate de conocer bien el mapa de la memoria RAM en la arquitectura x86. Te ayudará a prevenir errores relacionados con la memoria, que pueden llegar a ser muy difíciles de solucionar a posteriori.
Organizar las carpetas de nuestro proyecto
Nuestro proyecto se está haciendo mayor. Ahora vamos a tener 2 programas distintos dentro de nuestro código (stage1 y stage2), y el proceso de compilación se va a hacer un poquito más lioso. Por eso, debemos tener claro cómo vamos a organizar nuestro proyecto antes de comenzar.
La organización que te voy a proponer a lo largo de este capítulo soluciona varios de nuestros problemas:
- Diferencia claramente los archivos por su nombre (hasta ahora, hemos llamado boot a casi todo).
- Separa claramente el código de la stage1 y de la stage2.
- Separa claramente los archivos de compilación (carpeta build) de los archivos de código (carpeta src).
Por supuesto, hay problemas añadidos que no soluciona. En el futuro, crearemos una estructura de carpetas que nos permita compilar cada programa de forma independiente, y nos permita expandir el programa tanto como queramos. Hasta entonces, nos quedaremos con esta estructura provisional para la carpeta src:
src
|-- stage1
| |-- stage1.asm
|-- stage2
| |-- entry.asm
| |-- linker.ld
Esta estructura se diferencia de la que hemos seguido hasta ahora porque:
- Hemos colocado la stage1 y la stage2 en carpetas separadas.
- Hemos cambiado el nombre del archivo de código de la stage1 de boot.asm a stage1.asm.
- Hemos creado los archivos entry.asm y linker.ld en la carpeta stage2, aunque por ahora están vacíos.
Definir la estructura del programa
¿Recuerdas el linker? Sí, ese programa que estamos utilizando para generar nuestro ejecutable después de compilarlo, como vimos en la introducción del bloque 1. Hasta ahora hemos creado programas de un solo archivo, así que no ha hecho falta configurar mucho... pero la stage2 va a estar compuesta por muchos archivos. Por tanto, hay que crear un linker script (nosotros lo llamaremos linker.ld) que defina las diferentes secciones de nuestro ejecutable.
Aquí tienes una propuesta para tu archivo linker.ld. Está escrito usando la sintaxis de ld y define varias secciones, que están explicadas en las anotaciones.
/*
* Punto de entrada del programa: símbolo "entry".
* Formato del programa: binario (.obj)
*/
ENTRY(entry)
OUTPUT_FORMAT("binary")
SECTIONS
{
/* La dirección del programa en la RAM (.) es 0x500 */
. = 0x500;
/* Sección "entry": punto de entrada del programa */
.entry : { __entry_start = .; *(.entry) }
/* Sección "text": código posterior */
.text : { __text_start = .; *(.text) }
/* Sección "data": variables inicializadas */
.data : { __data_start = .; *(.data) }
/* Sección "rodata": variables de solo lectura */
.rodata : { __rodata_start = .; *(.rodata) }
/* Sección "bss": variables no inicializadas */
.bss : { __bss_start = .; *(.bss) }
/* La variable "__end" apunta al inicio del programa */
__end = .;
}
Desde el linker script hemos definido algunas variables que podremos usar en cualquier punto de la stage2, que son __entry_start, __text_start, __data_start, __rodata_start y __bss_start. Contienen la dirección de cada sección del ejecutable en la RAM. Nos vendrán bien dentro de poco.
Escribir el código del programa
Aquí tienes una propuesta de código para tu archivo entry.asm. Lo único que hace es definir la función print (igual que en la stage1) y escribir un mensaje en la pantalla con el texto Hola.
bits 16
; INICIO DE LA SECCIÓN "entry"
section .entry
global entry ; Hacer el símbolo "entry" accesible para "linker.ld"
entry:
mov si, msg_boot ; Imprimir el mensaje
call print
halt: ; Parar ejecución
hlt
jmp halt
print: ; FUNCIÓN: print
mov ah, 0x0E
mov bl, 0
print_loop:
lodsb
cmp al, 0
jz print_ret
int 0x10
jmp print_loop
print_ret:
ret
msg_boot: ; Mensaje
db "Hola", 0x0A, 0x0D, 0
Compilar y enlazar el programa
Todos los archivos de código assembly de la stage2 pueden ser compilados con NASM, tal y como lo hemos hecho hasta ahora, aunque con un matiz. Usaremos el formato ELF, en vez de usar OBJ. También pondremos la extensión .obj, para diferenciarlos de su ejecutable final.
nasm -f elf -o entry.obj entry.asm
Cuando ya tengamos todos los archivos compilados, podemos enlazarlos todos en un solo ejecutable llamado stage2.bin. El siguiente comando usa la configuración en linker.ld para enlazar los archivos 1.obj, 2.obj y 3.obj en un entorno freestanding, sin utilizar la biblioteca estándar de C, y utilizando la biblioteca libgcc.
i686-elf-gcc -T linker.ld -nostdlib -o ejecutable.bin 1.obj 2.obj 3.obj -lgcc
En nuestro caso, enlazaremos entry.obj usando el siguiente comando:
i686-elf-gcc -T linker.ld -nostdlib -o stage2.bin entry.obj -lgcc
Conviene tener en cuenta que cuando ejecutemos estos comandos lo haremos desde nuestro archivo build.sh, que está en la carpeta raíz del proyecto. Por tanto, rutas de archivo como linker.ld se deberán escribir como src/stage2/linker.ld.
Copiar nuestro programa en el disco
¡Ya tenemos el archivo stage2.bin terminado! Tan solo tenemos que copiar el archivo al disco en el que estamos instalando nuestro sistema operativo. Por suerte, sabemos hacer eso usando mtools. Simplemente necesitamos el siguiente comando:
mcopy -i disco.img stage2.bin "::stage2.bin"
Puedes consultar el contenido del directorio raíz para ver si el archivo se ha copiado correctamente usando mdir tal que así:
mdir -i disco.img
Buscar stage2.bin en el directorio raíz
Esta sección asume que has ido siguiendo las anteriores. Por tanto, tu stage1 debería tener definido el header de FAT12 y las funciones start, start_checked, print, disk_reset, disk_getdriveparams, disk_read y sus dependencias.
Si no las tienes o las has programado por tu cuenta, procede con precaución. Existe un alto riesgo de que el programa deje de funcionar en algún punto del código. Ten paciencia.
Vamos a reanudar la programación de nuestra stage1 donde la dejamos. Por ahora, hemos asegurado el valor de los registros y hemos saltado a start_checked y hemos imprimido un texto que dice Hola. Seguiremos con nuestro código desde ahí, usando el resto de funciones que hemos ido programando.
Friendly reminder: todo el código de esta sección se debe escribir en nuestro archivo src/stage1/stage1.asm. El archivo antes se llamaba boot.asm. Si no le has cambiado el nombre, tenlo en cuenta cuando empecemos a compilar el programa.
Paso 1. Localizar el directorio raíz
Tras el símbolo start_checked, consultaremos los parámetros del disco y los usaremos para calcular el LBA en el que empieza nuestro directorio raíz y su tamaño máximo. Para ello, vamos a usar las fórmulas que ya conocemos e instrucciones aritméticas simples:
start_checked:
; Consultar parámetros del disco
call disk_getdriveparams
; rootlba = sectors_per_fat * fat_count + reserved_sectors
mov ax, [bdb_sectors_per_fat]
mov bl, [bdb_fat_count]
xor bh, bh
mul bx
add ax, [bdb_reserved_sectors]
push ax
; AX = tamaño del directorio raíz (en sectores)
; rootsize = (dir_entries_count * 32) / bytes_per_sector
mov ax, [bdb_dir_entries_count]
shl ax, 5
xor dx, dx
div word [bdb_bytes_per_sector]
; Si el resto es 0, rootsize es un núm. entero, así que saltamos a la sig. sección
test dx, dx
jz start_rootread
; Si no, sumar 1 antes de saltar
inc ax
jmp start_rootread
Paso 2. Buscar stage2.bin dentro del directorio raíz
Como ya sabemos, en el sistema de archivos FAT12 una carpeta está compuesta por entradas de 32 bytes que nos indican datos básicos sobre cada uno de sus archivos. Estas entradas son consecutivas. Si se terminan antes de llegar al límite del directorio raíz, solo encontramos bytes con el valor 0.
También sabemos que, dentro de los 32 bytes que componen cada entrada, los 11 primeros contienen el nombre del archivo. El nombre de nuestro archivo una vez incluido en el disco será STAGE2 BIN. Por todo esto, nuestro método de búsqueda será iterar por cada entrada y comprobar si sus primeros 11 bytes coinciden con ese nombre de archivo.
Si encontramos una entrada cuyo nombre sea STAGE2 BIN, cargaremos el contenido del archivo en la RAM y saltaremos hacia la dirección en la que lo hayamos cargado. Si no lo encontramos, lanzaremos un mensaje de error y reiniciaremos el equipo.
Para empezar, debemos definir una variable cuyo valor sea el nombre de archivo de nuestra stage2. El nombre es arbitrario, pero debe ocupar 11 bytes. El nombre de la variable también es arbitrario. Puedes usar el siguiente ejemplo:
file_stage2_bin:
db "STAGE2 BIN"
Para leer los contenidos de la carpeta raíz hacia la RAM utilizaremos nuestra función disk_read, que necesita que le proporcionemos un buffer hacia el que leer los datos. Para ello, tras nuestra firma de boot, definiremos un símbolo llamado buffer en el que escribiremos los datos que vayamos leyendo.
; Firma
times 510-($-$$) db 0
dw 0xAA55
; AQUÍ situaremos nuestro buffer
buffer:
En el ejemplo que desarrollaremos a continuación, también hemos definido una función llamada err_stage2_not_found, que se ejecutará cuando no se encuentre el archivo. Aquí se muestra una implementación muy simple, aunque demasiado mínima, similar a la mostrada para la función err_disk. Es recomendable que se imprima un mensaje de error antes de reiniciar, pero eso te lo dejo a ti como tarea:
err_stage2_not_found:
mov ah, 0
int 0x16 ; INT 16h/AH=0: Esperar pulsación de una tecla
jmp 0x0FFFF:0 ; Saltar a la BIOS (reiniciar sistema)
A continuación, se muestra una implementación básica de nuestra función de búsqueda de archivos en x86 Assembly. Además, las anotaciones muestran una traducción de algunas partes del código a C:
start_rootread:
mov cl, al ; cl = tamaño del directorio raíz
pop ax ; ax = LBA del directorio raíz
mov dl, [ebr_drive_number]
mov bx, buffer
call disk_read
xor bx, bx ; bx = contador de entradas
mov di, buffer ; di = puntero hacia el buffer
jmp start_search_stage2
start_search_stage2:
mov si, file_stage2_bin ; si = "STAGE2 BIN"
mov cx, 11 ; cx = 11 (bytes del nombre)
; Comparamos los 11 caracteres de nuestra string con
; los del nombre definido en la entrada actual.
; bool is_stage2 = memcmp(es:di, ds:si, cx);
push di
repe cmpsb
pop di
; Si son iguales, continuamos hacia la siguiente sección.
; Si no, avanzamos 32 bytes y sumamos una entrada más.
; if(is_stage2) start_read_stage2();
; else { di += 32; bx++; start_search_stage2(); }
je start_read_stage2
add di, 32 ; di = di + 32 (tamaño de entrada en FAT12)
inc bx
; Si la carpeta raíz no se ha acabado, repetimos bucle.
; Si se ha acabado, lanzamos un mensaje de error.
; if(bx < bdb_dir_entries_count) start_search_stage2();
; else err_stage2_not_found();
cmp bx, [bdb_dir_entries_count]
jl start_search_stage2
jmp err_stage2_not_found
start_read_stage2:
; siguiente código ...
Este fragmento de código es un poco más complicado que el resto de los ejemplos mostrados en este manual. Tómate tu tiempo para entenderlo correctamente, y ten en cuenta las siguientes aclaraciones:
- La instrucción repe repite una instrucción el número de veces indicado en cx. También se detiene si la zero flag (que almacena el resultado de cmpsb) es igual a 1 (es decir, si las letras comparadas no son iguales). Se usa para comparar cada letra del nombre de la entrada actual con nuestra cadena de texto (STAGE2 BIN)
- La función start_read_stage2 será la siguiente que definamos. Por ahora, se ha dejado vacía a propósito.
- La instrucción cmpsb (y todas las instrucciones que trabajan con strings) incrementa por sí sola el valor del registro di cada vez que es ejecutada. Por eso se puede ejecutar en bucle sin causar ningún problema.
A partir de aquí, si tenemos un archivo en la carpeta raíz llamado STAGE2 BIN, el programa avanzará hasta la sección start_read_stage2. El siguiente paso consistirá en leer los contenidos del archivo.
Leer los contenidos de stage2.bin
¿Recuerdas el apartado de teoría en el que explicamos cómo leer el contenido de un archivo en la sección de datos de FAT12? Pues ahora lo vamos a llevar a la práctica. Vamos a encontrar el primer sector de stage2.bin, vamos a leerlo y usaremos la FAT para ir localizando los demás hasta llegar al límite del contenido.
Paso 1. Localizar el primer cluster del archivo
Este paso es muy fácil de realizar. El primer cluster de cualquier archivo viene directamente escrito en su entrada, igual que su nombre y su tamaño en bytes. Si no recuerdas esto bien, puedes consultar el apartado sobre la carpeta raíz del capítulo 4.
Al llegar a start_read_stage2, tenemos almacenada en el registro di la dirección de la entrada del archivo. Si a esa dirección le sumamos el offset en el que se encuentran los últimos 2 bytes del primer cluster (0x1A), tendremos un pointer hacia el número que queremos conseguir. Basta con desreferenciar el puntero para obtener el número en sí. En NASM Assembly, esto se hace rodeando la referencia con corchetes, tal que así:
start_read_stage2:
; AX = últimos 2 bytes del primer cluster del archivo
mov ax, [di + 0x1A]
Este código tiene ciertas limitaciones que luego explicaremos, aunque es una buena opción para el sistema que estamos programando.
Para asegurarnos de que no perdemos este valor, vamos a guardarlo directamente en nuestro ejecutable bajo el símbolo data_stage2_cluster, que contendrá una variable de 2 bytes:
start_read_stage2:
mov ax, [di + 0x1A]
mov [data_stage2_cluster], ax
; ... avanzar hasta donde definas tus variables ...
data_stage2_cluster:
dw 0
Paso 2. Guardar la FAT en nuestro buffer
Hacer esto también es fácil. Se trata de hacer una simple llamada a nuestra función disk_read. Tenemos ya calculados los 4 argumentos necesarios para llamarla:
- LBA (ax): La dirección LBA de la FAT es equivalente al número de sectores reservados, que está guardado en bdb_reserved_sectors.
- Buffer (bx): Usaremos el símbolo buffer que ya hemos definido anteriormente.
- Número de sectores a leer (cl): usaremos los sectores que ocupa una FAT, definidos en bdb_sectors_per_fat.
- Número de disco (dl): lo tenemos almacenado en ebr_drive_number.
Y, sin más dilación, la llamada como tal:
mov ax, [bdb_reserved_sectors]
mov bx, buffer
mov cl, [bdb_sectors_per_fat]
mov dl, [ebr_drive_number]
call disk_read
Paso 3. Calcular el LBA de la data section
No explicaré muchos detalles debido a que ya conocemos las fórmulas y ya hicimos los cálculos en el capítulo 4. Lo único nuevo de esta sección es que los cálculos están en assembly, y no en C.
Para empezar, definiremos un símbolo bajo el que almacenaremos el LBA de la data section:
data_datasectionlba:
dw 0
A continuación, calculamos su valor y lo almacenamos:
; rootsize (cx) = dir_entries_count / bytes_per_sector / 32;
mov cx, [bdb_bytes_per_sector]
shr cx, 5
mov ax, [bdb_dir_entries_count]
div cx
mov cx, ax
; fatsize (ax) = fat_count * sectors_per_fat
mov ax, [bdb_sectors_per_fat]
mul byte [bdb_fat_count]
; datasectionlba (cx) = reserved_sectors + fatsize + rootsize
add cx, ax
add cx, [bdb_reserved_sectors]
mov [data_datasectionlba], cx
En este código, se usa la instrucción shr cx, 5 debido a que es equivalente a dividir entre 32, pero su ejecución es más rápida y clara que la de la instrucción div. Además, nos ahorramos guardar un resto que no vamos a usar para nada.
Paso 4. Decidir en qué dirección cargaremos la stage2.
Hasta ahora, hemos estado usando nuestro símbolo buffer para cargar contenido, pero la decisión sobre dónde cargar la stage2 es un poco más compleja. Ya lo hemos cubierto en la sección sobre la creación de nuestra stage2. En este capítulo cargaremos nuestro programa en la dirección 0x500.
Sea como sea, conviene definir el segmento y el offset en el que cargaremos la stage2 como 2 constantes dentro de nuestro código. En NASM, podemos hacerlo usando la directiva equ:
STAGE2_LOAD_SEGMENT: equ 0
STAGE2_LOAD_OFFSET: equ 0x500
Para cargar el programa en la memoria, simplemente leeremos el primer sector hacia 0x500, el segundo hacia 0x700... y así, llamando a disk_read una y otra vez hasta que terminemos. La función espera que anotemos el segmento en el registro es y el offset en el registro bx. Como el extra segment (es) no se puede manipular directamente, lo haremos así:
mov bx, STAGE2_LOAD_SEGMENT
mov es, bx
mov bx, STAGE2_LOAD_OFFSET
; Hecho esto, saltamos al loop en el que leeremos los sectores.
jmp start_read_stage2_loop
Paso 5. Leer el archivo sector a sector
Hemos cubierto este proceso anteriormente, pero puede resultar complicado de entender, así que lo resumiré aquí. Si un archivo comienza en el cluster nº 2, tendríamos que seguir los siguientes pasos:
- Traducir cluster a LBA: si la data section empieza en el sector 31, nuestro LBA sería 31 + (2-2) = 31.
- Leer el sector nº 31.
- Consultar la entrada nº 2 de la FAT*, que nos indica el siguiente cluster (pongamos que es el 10).
- Traducir cluster a LBA: 31 + (10-2) = 39.
- Leer el sector nº 39.
- Consultar la entrada nº 10 de la FAT*, que nos indica el siguiente cluster (pongamos que es el 11).
- Cluster 11: traducir, leer, consultar...
- Cluster 12: traducir, leer, consultar...
- Cluster 20: traducir, leer, consultar...
- Seguimos hasta que la siguiente entrada de la FAT apunte al cluster FF8 o más. Eso nos indica que hemos leído el archivo completo.
*Debido a que cada entrada ocupa 12 bits (1,5 bytes) calcular el byte en el que se encuentra cada entrada es un poco complicado. La entrada 0 empieza en el byte 0, la entrada 1 empieza en el byte 1, la entrada 2 empieza en el byte 3... En cualquier caso, el byte de comienzo (llamado índice o fatindex) se calcula siguiendo la fórmula cluster / 2 * 3. Se entiende mejor en el código.
Una implementación válida de ese proceso en NASM se escribiría de una forma parecida a esta:
start_read_stage2_loop:
; Traducir cluster a LBA
; stage2_lba (ax) = datasectionlba + (stage2_cluster - 2)
mov ax, [data_stage2_cluster]
sub ax, 2
add ax, [data_datasectionlba]
; Leer el cluster usando la función disk_read
mov cl, [bdb_sectors_per_cluster]
mov dl, [ebr_drive_number]
call disk_read
; Mover bx 512 bytes adelante para escribir el siguiente sector
add bx, [bdb_bytes_per_sector]
; Calcular el índice de la entrada de la FAT que debemos consultar
; fatindex (ax) = cluster / 2 * 3
mov ax, [data_stage2_cluster]
mov cx, 3
mul cx
mov cx, 2
div cx
; Consultar la entrada para saber el siguiente cluster
; ax = Contenido de la entrada FAT en fatindex (sig. cluster)
mov si, buffer
add si, ax
mov ax, [ds:si]
; Comprobar si el índice de la entrada es par o impar
or dx, dx
jz start_read_stage2_even
start_read_stage2_odd:
; Si es impar, descartar sus últimos 4 bits
shr ax, 4
jmp start_read_stage2_nextloop
start_read_stage2_even:
; Si es par, descartar sus primeros 4 bits.
and ax, 0x0FFF
start_read_stage2_nextloop:
; Si el cluster >= 0xFF8, continuar (hemos leído toda la stage2)
cmp ax, 0x0FF8
jae start_read_stage2_jump
; Si no lo es, continuar leyendo el siguiente sector
mov [data_stage2_cluster], ax
jmp start_read_stage2_loop
Es normal si no entiendes este código a la primera, pero si te cuesta mucho entenderlo te recomiendo repasar la sección sobre la estructura de FAT12. Sobre todo, lo relativo a la sección de FAT y su división en entradas de 12 bits. En el futuro, implementaremos esta misma función en C, y eso te puede ayudar a entenderlo mejor.
Saltar a la stage2
Este es el momento de la verdad. Hemos copiado todo el contenido de nuestra stage2 en la dirección 0x500 de la RAM, nos hemos asegurado de que el ejecutable empieza directamente con el código. Lo único que nos queda es ejecutar el archivo.
¿Y cómo se ejecuta un archivo? Por ahora, la única manera que tenemos es saltando. Ahora mismo estamos ejecutando código en una dirección cercana a 0x7E00. Simplemente tenemos que cambiar esa dirección de ejecución a 0x500 mediante lo que se conoce como un far jump.
Esta es la forma segura de ejecutar ese far jump. Además, mediante este método mantenemos el número de disco guardado en el registro dl una vez entremos en la stage2:
start_read_stage2_jump:
; Almacenar el número de disco en 'dl'
mov dl, [ebr_drive_number]
; Configurar el nuevo segmento
mov ax, STAGE2_LOAD_SEGMENT
mov ds, ax
mov es, ax
; Far jump hacia stage2.bin
jmp STAGE2_LOAD_SEGMENT:STAGE2_LOAD_OFFSET
; El proceso no debería volver nunca desde stage2.bin
; Si lo hace, esperaremos la pulsación de una tecla y
; reiniciaremos el sistema.
mov ah, 0
int 0x16 ; INT 16h/AH=0: Esperar pulsación de una tecla
jmp 0x0FFFF:0 ; Saltar a la BIOS (reiniciar sistema)
A partir de este punto, hemos transferido el control de ejecución a stage2.bin. En el nuevo ejecutable, nuestro límite de espacio asciende de 512 bytes a 29,75 KiB. Eso nos permitirá ejecutar operaciones mucho más complejas y saltar a funciones escritas en C, aunque para llegar ahí todavía nos queda un paso más: pasar de real mode a protected mode.
Cómo comprobar su funcionamiento
Puedes comprobar si el bootloader funciona correctamente compilándolo y luego emulándolo con QEMU. Aquí tienes una recapitulación de los comandos necesarios para compilar, enlazar y emular nuestro bootloader. Esta debería ser, aproximadamente, la estructura de tu script build.sh.
Tras ejecutar el script, tendremos la siguiente estructura de carpetas dentro de nuestro proyecto:
proyecto
|-- build
| |-- bin
| | |-- stage1.bin
| | |-- stage2.bin
| |-- obj
| | |-- entry.obj
| |-- disco.img
|-- src
| |-- stage1
| | |-- stage1.asm
| |-- stage2
| |-- entry.asm
| |-- linker.ld
|-- build.sh
Teniendo en cuenta cómo hemos organizado nuestros proyectos y cómo queremos que quede nuestra carpeta build, el script debería ser parecido a este:
# Definir variables
GCC="ubicación de tu cross-compiler de GCC para i686-elf"
# Asegurarse de borrar builds desactualizadas
rm -rf build
# Crear la carpeta 'build' y todas las subcarpetas necesarias
mkdir -p build/bin
mkdir -p build/obj
# Compilar stage1.bin
nasm -f bin -o build/bin/stage1.bin src/stage1/stage1.asm
# Compilar y enlazar stage2.bin
nasm -f elf -o build/obj/entry.obj src/stage2/entry.asm
${GCC} -T src/stage2/linker.ld -nostdlib -o build/bin/stage2.bin build/obj/entry.obj -lgcc
# Formatear el disco
dd if="/dev/zero" of="build/disco.img" bs=512 count=2880
mkfs.fat -F 12 -n "SCRATCH OS" build/disco.img
# Instalar stage1
dd if="build/bin/stage1.bin" of="build/disco.img" conv=notrunc
# Instalar stage2
mcopy -i build/disco.img build/bin/stage2.bin "::stage2.bin"
# Emular el bootloader con QEMU
qemu-system-i386 -fda build/disco.img
Tras ejecutar esto, debería aparecer una pantalla en la que se emula un equipo iniciado desde la imagen disco.img. En el equipo emulado debería aparecer en la última linea de texto el mensaje "Hola". Si no aparece, ha ocurrido algún error.
Resumen
Este es uno de los capítulos con más contenido del bloque. Hemos aprendido a crear programas, enlazarlos y copiarlos en nuestro disco, a buscarlos dentro del directorio raíz, a leer su contenido utilizando funciones en assembly y a ejecutar su código mediante un far jump.
A partir de aquí, nuestro trabajo se centrará en pasar del modo real del procesador (en el que estamos actualmente) al modo protegido, que nos permitirá emitir código de 32 bits y acceder a la memoria sin preocuparnos de los segmentos, entre otras muchas cosas.
Después de eso, pasaremos de assembly a C, y haremos las últimas preparaciones para terminar nuestro bootloader y ceder el control al kernel, el primer programa de nuestro sistema operativo.
Ejemplo descargable
A estas alturas, puede ser que tu proyecto se haya desviado un pelín del ejemplo que desarrollo en este capítulo. De todas formas, si te interesa consultar el código que llevamos hasta ahora, descarga el archivo capitulo-6.tar.gz, descomprímelo y ejecuta el script build.sh.