Capítulo 5: Leer y manipular el disco


Ya hemos formateado nuestro disco y conocemos el sistema de archivos FAT12. Ya sabemos cómo está estructurado el contenido de nuestro disco y cómo interpretar los bytes incluidos en cada sector. En este episodio, vamos a meter algunos archivos en el disco e incluir funciones en nuestro bootloader que nos permitan leerlos.

Tabla de contenidos


  1. Operación 1: Reiniciar el disco
  2. Operación 2: Leer parámetros del disco
  3. Operación 3: Leer sectores del disco
  4. Resumen

Operación 1: Reiniciar el disco


Antes de programar funciones más complicadas, como la que usaremos para leer archivos, primero tenemos que programar la función de reiniciar disco. Esta función entrara en juego cuando cualquier operación falle, y la propia documentación que usaremos (la Ralf Brown's Interrupt List) recomienda tenerla escrita en nuestro programa. Su código es el siguiente:

disk_reset:
    pusha            ; Guardar registros en el stack
    
    mov ah, 0
    int 0x13         ; INT 13h/AH=0: Reinicia el disco

    jc err_disk      ; Salta a err_disk si ha ocurrido un error
    
    popa             ; Restaurar los registros
    ret

err_disk:
    mov ah, 0
    int 0x16         ; INT 16h/AH=0: Esperar pulsación de una tecla

    jmp 0x0FFFF:0    ; Saltar a la BIOS (reiniciar sistema)

Vamos a explicar las instrucciones nuevas:

Los 2 interrupts que hemos usado son el INT 13h/AH=00 y el INT 16h/AH=00.

Este es un buen momento para explicar un registro muy importante: el registro FLAGS. Este registro ocupa 16 bits, y cada bit representa una flag (o variable booleana). Con la instrucción jc estamos comprobando el valor del primer bit del registro FLAGS, que se llama carry flag. Si su valor es 1, salta. Si no, no. Existen más flags, como la direction flag o la overflow flag. Te invito a investigarlas si tienes más curiosidad o planeas desarrollar más programas en assembly en un futuro.

Esta función es muy simple: usa un interrupt para reiniciar el disco, y, si no funciona, usa otro interrupt para esperar la pulsación de una tecla y reinicia el ordenador. Adicionalmente, también podrías usar la función print que ya programamos para poner un mensaje de error si así lo deseas.

Operación 2: Leer parámetros del disco


Todavía seguimos en la stage1 de nuestro bootloader. En este programa, nuestra meta es cargar el contenido de un archivo (la stage2) a la RAM para poder ejecutarlo. Para ello, tenemos que seguir los siguientes pasos:

  1. Averiguar parámetros del disco (como el número de cilindros, cabezales y sectores).
  2. Localizar la carpeta raíz de nuestro disco usando esos parámetros y el boot sector.
  3. Localizar, dentro de la carpeta raíz, la entrada del archivo que queremos leer.
  4. Dentro de esa entrada, localizar el primer cluster del archivo.
  5. Copiar su primer cluster en una dirección de la RAM y usar la FAT para localizar los siguientes y hacer lo mismo con ellos hasta el fin del archivo.
  6. Una vez hecho esto, si queremos ejecutar el archivo, solo tendremos que realizar un salto hacia la dirección de la memoria a la que hayamos cargado su contenido, usando la instrucción jmp.

En este apartado, aprenderemos a realizar el primero de esos pasos.

Para hacer esto, usaremos un interrupt un poco más avanzado que los anteriores: el INT 13h/AH=08h. En las siguientes tablas se ilustra, primero, los datos que el interrupt espera recibir; segundo, el estado de los registros tras haberlo llamado.

ah 0x08
dl número de disco
es:di 0x0000:0x0000
CF (Carry flag) Si hay error, 1. Si no, 0.
ah Código de error (0 en caso de éxito)
al 0 (en la mayoría de BIOS)
bl Tipo de disco (0x04 = Floppy 1.44 MB)
ch Máximo cilindro (últimos 8 bits)
cl Bits 5-0: máximo sector
Bits 7-6: máximo cilindro (primeros 2 bits)
dh Máximo cabezal
dl Número del disco
es:di Tabla de parámetros (si es un floppy)

El registro cx (que, como ya vimos, es la combinación de ch y cl) tiene un contenido un poco difícil de visualizar sin algo de ayuda. Además, está guardado en little endian (bits más significativos primero). Pongamos que, tras llamar a la función, el registro cx contiene los siguientes bits:

ch

7 6 5 4 3 2 1 0
1 0 1 0 0 0 0 1

cl

7 6 5 4 3 2 1 0
1 0 0 0 0 1 0 1

Los datos correspondientes al cilindro muestran los bits 10100001 10, y los correspondientes al sector muestran los bits 000101. Cada número se debe leer al revés, dado que están escritos siguiendo el orden little endian. Además, debemos tener en cuenta que los bytes incluidos en ch deben colocarse después de los 2 primeros de cl.

Teniendo en cuenta eso, el máximo cilindro del disco sería 01 10000101 (en decimal: 389) y el máximo sector sería 101000 (en decimal: 40). Para aislar estos 2 números usando assembly, tendremos que usar algunos operadores a nivel de bit (en inglés, bitwise operators).

El código de nuestra función se encargará de llamar al interrupt INT 13h/AH=08h, comprobar si ha habido algún error y organizar correctamente el contenido de los registros. A continuación, presento la implementación que creé para mi sistema scratchOS. El código introduce por primera vez las instrucciones jg (saltar si un valor es mayor que otro), and (realizar la operación lógica AND) y xor (realizar la operación lógica EXCLUSIVE OR).

disk_getdriveparams:
    ; Guardar el extra segment
    push es
    
    ; Llamar al interrupt
    mov ah, 0x08
    mov dl, [ebr_drive_number]
    int 0x13
    
    ; Comprobar si ha habido un error
    jc err_disk
    
    ; Comprobar si ha devuelto un número de disco incorrecto
    cmp [ebr_drive_number], dl
    jg err_disk                      ; Si dl > ebr_drive_number, jmp err_disk
    
    ; Restaurar el extra segment
    pop es
    
    ; Guardar el número de sectores por track
    and cl, 0x3F                       ; cl = cl & 0x3F
    xor ch, ch                         ; ch = 0
    mov [bdb_sectors_per_track], cx
    
    ; Guardar el número de cabezales
    inc dh
    mov [bdb_heads], dh

De esta forma nos aseguramos de que todos los datos de nuestro boot sector son correctos. El siguiente paso para poder leer archivos es crear una función para leer sectores del disco hacia la memoria RAM.

¿Pero cómo hemos separado el máximo cilindro del máximo sector? Pues utilizando el operador AND. Siguiendo con el ejemplo anterior, en ch podemos encontrar el byte 10100001, y en cl podemos encontrar el byte 10000101.

Para quedarnos únicamente con los primeros 2 bits de un número, realizamos una operación AND con el número 00111111 (en hexadecimal: 0x3F). En esta operación, si los 2 bits son 1, el resultado es 1, y en cualquier otro caso, el resultado es 0. Veamos cómo se hace la operación con el contenido de cl:

  10000101
& 00111111
  --------
  00000101

El resultado de esta operación son los últimos 6 bits aislados. En nuestro caso, esto equivale al número máximo de sector, o, dicho de otra forma, el número de sectores por cilindro. En esta implementación, hemos perdido la información sobre el número máximo de cilindro, porque no la vamos a utilizar.

Operación 3: Leer sectores del disco


Para realizar esta operación usaremos el interrupt INT 13h/AH=02h. Esta función lee hacia la dirección es:bx de la RAM un sector de la memoria localizado en una dirección CHS del disco que proporcionemos. En concreto, el método espera encontrar los siguientes datos en los registros:

ah 0x02
al número de sectores a leer
ch Cilindro (últimos 8 bits)
cl Bits 0-5: sector
Bits 6-7: cilindro (primeros 2 bits)
dh número del cabezal
dl número del disco
es:bx dirección en la que guardar los datos

Traducir LBA a CHS

Proporcionar la dirección CHS cada vez que queramos leer un sector y además organizarla de esta forma en los registros, resulta muy molesto. Diseñaremos nuestra función para que reciba la dirección LBA del sector que queramos leer, e incorporaremos un traductor de LBA a CHS que organice el contenido como pide el interrupt.

Aquí adjunto la implementación de nanobyte para su sistema operativo nanobyteOS:

lba_to_chs:
    ; SE ESPERA:
    ;     ax = LBA del sector a traducir

    ; SE DEVUELVE:
    ;     ch = cilindro (últimos 8 bits)
    ;     cl = bits 0-5: sector
    ;          bits 6-7: cilindro (primeros 2 bits)
    ;     dh = cabezal

    ; Guardar registros
    push ax                             ; Guardar 'ax'
    push dx                             ; Guardar 'dx'

    ; sector = lba % sectors_per_track + 1
    xor dx, dx                          ; dx = 0
    div word [bdb_sectors_per_track]    ; ax = lba / sectors_per_track
                                        ; dx = lba % sectors_per_track
    inc dx                              ; dx = dx + 1
    mov cx, dx                          ; cl = sector

    ; cylinder = lba / sectors_per_track / heads
    xor dx, dx                          ; dx = 0
    div word [bdb_heads]                ; ax = cilindro
    mov ch, al                          ; ch = cilindro (últimos 8 bits)
    shl ah, 6                           ; ah (bits 6-7) = cilindro (primeros 2 bits)
    or cl, ah                           ; cl (bits 6-7) = cilindro (primeros 2 bits)

    ; head = lba / sectors_per_track % heads
                                        ; dl = cabezal (resto de la anterior division)
    mov dh, dl                          ; Mover cabezal a dh

    ; Restaurar los registros
    pop ax                              ; Restaurar 'dx' moviéndolo a 'ax'
    mov dl, al                          ; Mover hacia 'dl' para no sobreescribir el cabezal (guardado en 'dh')
    pop ax                              ; Restaurar 'ax'
    ret

Aquí introducimos la operación OR, que devuelve 1 si cualquiera de los dos bits es 1, y devuelve 0 en cualquier otro caso. Aquí tienes un ejemplo:

  11001111
| 00001111
  --------
  11001111

En este caso, usamos la operación para trasladar solo ciertos bits de un registro a otro sin sobreescribir los bits que queremos conservar, aprovechando que un OR con 1 siempre convierte el bit en 1 y un OR con 0 lo mantiene. Así, por ejemplo, si queremos mantener únicamente los primeros 3 bits de un byte, podemos hacer un OR con el byte 11100000.

El método tiene muchas operaciones bastante condensadas, así que te recomiendo tomarte tu tiempo y leerlo varias veces si quieres entenderlo bien. Por ahora, lo más importante es que entiendas que si quieres traducir el LBA 22 a una dirección CHS, puedes hacerlo así:

mov ax, 22
call lba_to_chs

Llamar al interrupt

La función en sí, quitando el traductor, es bastante simple. Una mala implementación simplemente llamaría al interrupt de una forma parecida a esta:

; Este código está incompleto.

disk_read:
    ; SE ESPERA:
    ;    ax = LBA del sector a leer
    ;    al = número de sectores que leer
    ;    dl = número del disco
    ;    es:bx = Dirección de la memoria en la que guardarlo

    push ax          ; Guardar registros
    push cx
    push dx

    call lba_to_chs  ; Guardar CHS en ch, cl y dh

    stc              ; CARRY FLAG = 1
    mov ah, 0x02     ; ah = 0x02 (para el int)
    pusha            ; Guardar registros para el int
    int 0x13
    popa             ; Restaurarlos tras el int

    jc err_disk      ; Si la carry flag == 1, ha habido un error.

    pop dx           ; Restaurar registros
    pop cx
    pop ax

Lo más novedoso que hemos hecho aquí es asegurarnos de que la carry flag tiene el valor de 1 modificándola mediante el comando stc. Hacemos esto porque algunas BIOS simplemente cambian el valor de la carry flag en caso de error. Si nos aseguramos de que es 1, un valor de 0 siempre indicará error.

Pero esa implementación tiene un problema muy importante. La documentación que estamos usando afirma que es muy probable que sucedan errores al intentar leer datos por primera vez, sobre todo si usamos un floppy físico. Por ello, se necesita repetir la operación al menos 3 veces antes de darla por fallida. En C, podríamos hacerlo así:

for(int i = 0; i < 3; i++)
    if(disk_read()) return true;

return false;

Los bucles en assembly necesitan algunas lineas más de código:

disk_read:
    push ax                ; Guardar registros
    push bx
    push cx
    push dx
    push di

    push cx                ; Guardar cx (número de sectores que leer)
    call lba_to_chs        ; Guardar CHS en ch, cl y dh
    pop ax                 ; al = número de sectores que leer

    mov ah, 0x02
    mov di, 3              ; di = 3 (lo usaremos como contador de bucles)

disk_read_loop:
    pusha                  ; Guardar registros para el int
    stc                    ; Activar la carry flag
    int 0x13
    popa                   ; Recuperar registros

    jnc disk_read_return   ; Si la carry flag == 0, terminar bucle.
    
                           ; Si no:
    call disk_reset        ;     1. Resetear el disco
    dec di                 ;     2. di = di - 1 (un bucle menos)
    test di, di            ;     3. Compara di consigo mismo
    jnz disk_read_retry    ;     4. Si 'di' no es 0, nuevo bucle.
    
                           ; Si 'di' es igual a 0:
    jmp err_disk           ;     Saltar al mensaje de error.

disk_read_return:
    pop di                 ; Restaurar registros y volver
    pop dx
    pop cx
    pop bx
    pop ax
    ret

Este script, por mucho que abulte, es muy simple: traduce el LBA a CHS, llama al interrupt, y, si falla, lo vuelve a llamar hasta que el registro di tenga el valor de 0 (un máximo de 3 veces). Si falla las 3 veces, salta a nuestra función de error.

Cuando volvamos de la función, en la dirección de memoria es:bx estará escrito el contenido del sector que hayamos elegido. Esta es una de las funciones más importantes de nuestro sistema operativo, y actúa como único puente entre el disco duro y la RAM.

Resumen


En este capítulo hemos aprendido a realizar algunas operaciones de disco bastante simples, pero muy potentes. Ya podemos leer sectores del disco hacia nuestra memoria RAM y también reiniciar el disco en caso de fallo. En el siguiente capítulo, usaremos estas funciones para leer archivos sector a sector y cargar así la stage2 de nuestro bootloader.

Desde la stage2, tendremos más libertad para implementar funciones algo más avanzadas, como poner el procesador en protected mode y cargar el kernel, que será el primer programa de nuestro sistema operativo.