Evolucionando el bootloader en BIOS Legacy/x86 [2 de 5]

Compartir

Por Román Ramirez

En el artículo anterior vimos lo más básico de un bootloader —TAN básico que no hace absolutamente nada: simplemente arrancar.

Ahora evolucionaremos un poco el bootloader para que haga alguna cosa más, por ejemplo, imprimir "cosas" por pantalla. Pero antes de tener el bootloader funcional, probaremos algunos cambios simplemente por curiosidad.

Si recuerdas del artículo anterior, rellenábamos con 0 los 512 bytes que conforman el bootloader (salvo los dos últimos, que son la firma). Vamos a hacer una prueba rellenando con la instrucción nop (0x90 en hexadecimal una vez ensamblada). Veamos el fuente, simple.asm:

[BITS 16]               ; Modo real, 16 bits
[ORG 0x7C00]            ; Origen: 0x7C00


start:
    jmp $


times 510-($-$$) nop    ; Rellenamos con NOP hasta los 510 bytes
dw 0xAA55               ; Firma de arranque

Lo ensamblamos (nasm -f bin simple.asm -o simple.bin) y obtenemos un binario que podemos ver con hexdump:

$ hexdump -C simple.bin
00000000  eb fe 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000010  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
*
000001f0  90 90 90 90 90 90 90 90  90 90 90 90 90 90 55 aa  |..............U.|
00000200

Podemos confirmar que, efectivamente, en vez de valores 0 ahora tenemos valores 90 (0x90, nop). Este binario se comportará exactamente igual que la versión anterior, pero ojo al motivo: no es que 0x00 y 0x90 hagan lo mismo. Los dos primeros bytes, EB FE, son nuestro jmp $: un salto relativo de −2 bytes que vuelve sobre sí mismo. El procesador se queda dando vueltas ahí para siempre y nunca llega a ejecutar el relleno, sea lo que sea.

De hecho, si llegara a ejecutarlo, no sería lo mismo: la pareja 00 00 se decodifica como add [bx+si], al, que escribe en memoria, mientras que 0x90 es un auténtico "no hacer nada". (Curiosidad: en x86, 0x90 es en realidad la codificación de xchg ax, ax, intercambiar AX consigo mismo.)

Otra manera de lograr esto mismo es definir directamente el byte: times 510-($-$$) db 0x90.

El Modo Real (16 bits)

Cuando la BIOS carga nuestro bootloader en memoria (recuerda, en la dirección 0x7C00) y le cede el control, el procesador está en lo que se conoce como Real Mode (Modo Real). En realidad no "entra" en ese modo en ese momento: un x86 arranca siempre en Modo Real desde el reset y ahí sigue mientras nadie lo cambie.

El nombre viene de que las direcciones que usamos son direcciones reales, es decir, físicas: no hay tablas de descriptores ni paginación que las traduzcan, a diferencia del modo protegido que llegó con el 80286. La consecuencia práctica es que no hay protecciones de ningún tipo: ni zonas de memoria vedadas, ni aislamiento entre procesos o usuarios, ni anillos de privilegio. Podemos leer y escribir cualquier posición de memoria a nuestro alcance y hablar directamente con el hardware... con un matiz importante: "a nuestro alcance" significa, aproximadamente, el primer megabyte de memoria, como veremos.

Además, el microprocesador está en un modo donde las instrucciones operan por defecto con 16 bits. Esto no es tan rígido como parece, ojo. Sí, estamos en 16 bits, pero en cualquier procesador 80386 o posterior (es decir, en cualquier equipo que vayas a usar hoy, y en QEMU) podemos acceder a registros de 32 bits como EAX. En el 8086 original no existían: solo tenías AX. Lo que puede resultar chocante y abre una pregunta: ¿cómo hace el ensamblador para generar instrucciones de 16 bits que usan registros de 32 bits?

Excelente pregunta: usa el operand-size override prefix, añadiendo el byte 0x66 delante de la instrucción.

Veamos un ejemplo rápido ensamblando dos versiones del mismo programa, una moviendo el valor inmediato 1 al registro AX (simple_ax.asm) y otra al registro EAX (simple_eax.asm):

[BITS 16]
[ORG 0x7C00]


start:
    mov ax, 1
    jmp $


times 510-($-$$) nop
dw 0xAA55

Lo ensamblamos (nasm -f bin simple_ax.asm -o simple_ax.bin) y podemos ver su contenido con, por ejemplo, objdump:

$ objdump -D -b binary -m i386 -M i8086,intel \
          --adjust-vma=0x7c00 simple_ax.bin | less

simple_ax.bin:     formato del fichero binary

Desensamblado de la sección .data:

00007c00 <.data>:
    7c00:  b8 01 00             mov    ax,0x1
    7c03:  eb fe                jmp    0x7c03
    7c05:  90                   nop
...
    7dfd:  90                   nop
    7dfe:  55                   push   bp
    7dff:  aa                   stos   BYTE PTR es:[di],al

En este caso la instrucción ocupa tres bytes: el opcode B8 (mover un inmediato a AX) y el valor 1 en 16 bits, almacenado en little-endian (01 00). Veamos ahora la versión con EAX:

[BITS 16]
[ORG 0x7C00]


start:
    mov eax, 1
    jmp $


times 510-($-$$) nop
dw 0xAA55

Si la ensamblamos y repetimos con objdump sobre simple_eax.bin, veremos la diferencia:

$ objdump -D -b binary -m i386 -M i8086,intel \
          --adjust-vma=0x7c00 simple_eax.bin | less

simple_eax.bin:     formato del fichero binary

Desensamblado de la sección .data:

00007c00 <.data>:
    7c00:  66 b8 01 00 00 00    mov    eax,0x1
    7c06:  eb fe                jmp    0x7c06
    7c08:  90                   nop
...
    7dfd:  90                   nop
    7dfe:  55                   push   bp
    7dff:  aa                   stos   BYTE PTR es:[di],al

Ahora la instrucción empieza por el prefijo 0x66 y el inmediato ocupa 4 bytes (01 00 00 00). Ese prefijo le dice al procesador que, solo para esta instrucción, el tamaño de operando no es el de 16 bits que toca por defecto, sino 32. Dicho de otra forma: 0x66 invierte el tamaño de operando (en un segmento de 16 bits lo pasa a 32 y en uno de 32 lo pasa a 16). Fíjate también en que todo lo que viene detrás se ha desplazado tres bytes: el jmp está ahora en 0x7C06.

¡Hay otro prefijo hermano! El address-size override prefix (0x67) hace lo mismo con el tamaño de las direcciones, y lo usaremos cuando queramos direccionamiento de 32 bits desde código de 16. Aunque ya comentaremos sobre esto más adelante, porque todavía no hemos hablado de la memoria...

Antes de seguir, un detalle que no conviene dejar pasar: las dos últimas líneas de ambos volcados. objdump nos dice que en 0x7DFE hay un push bp y en 0x7DFF un stos. No es así y objdump los está interpretando incorrectamente: son los bytes 55 AA de la firma de arranque. Un desensamblador no distingue código de datos y lo interpreta todo como instrucciones. Tenlo presente cuando analices binarios ajenos, malware incluido: que algo se pueda desensamblar no significa que se ejecute realmente.

Direccionando memoria en 16 bits

En Modo Real es importante que recuerdes que tenemos 20 bits para direccionar (son las líneas de dirección A0 a A19). Con 20 bits, la teoría dice que podemos llegar a direccionar 2²⁰ posiciones, lo que nos da 1.048.576 (1 MB) posiciones de memoria de 1 byte.

¿Ya te has dado cuenta del problema? El 8086, el procesador del que heredamos este modo, solo tenía registros de 16 bits. Por mucho que las líneas de dirección nos permitan alcanzar 20 bits, una dirección completa no cabe en un registro. (Y aunque un 386 en Modo Real te deje usar EAX, el esquema de direccionamiento sigue siendo el del 8086: cada segmento sigue limitado a 64 KB.)

Esto nos deja con dos opciones:

  1. solamente usamos direcciones de 16 bits, quedándonos en 64 KB y desperdiciando el resto de las posiciones de memoria disponibles...
  2. usamos DOS registros.

Intel optó por la segunda y por eso, en Modo Real, hablamos de direccionamiento segmento:desplazamiento (segment:offset). El cálculo es muy simple:

dirección física = (segmento × 16) + offset

Una operación equivalente es desplazar el segmento 4 bits a la izquierda (desplazar n bits a la izquierda es multiplicar por 2ⁿ, y 2⁴ = 16):

dirección física = (segmento << 4) + offset

Para formar esa pareja tenemos varios registros de segmento, cada uno con su papel por defecto:

  • CS (Code Segment): junto con IP, indica la siguiente instrucción a ejecutar (CS:IP).
  • DS (Data Segment): segmento por defecto para los datos (por ejemplo, DS:SI en lodsb).
  • SS (Stack Segment): junto con SP, define la pila (SS:SP).
  • ES (Extra Segment): segmento adicional; es el destino de las instrucciones de cadena (ES:DI en stosb).
  • FS y GS: dos segmentos extra que aparecen con el 386.

Vamos con un ejemplo práctico: ¿dónde está nuestro bootloader? La BIOS carga el sector de arranque en la dirección física 0x7C00. Lo más habitual es que nos ceda el control con CS=0x0000 e IP=0x7C00:

0x0000 << 4 = 0x00000

0x00000 + 0x7C00 = 0x07C00 (31.744 en decimal)

Pero no todas las BIOS lo hacen así. Algunas saltan a CS=0x07C0 e IP=0x0000:

0x07C0 << 4 = 0x07C00

0x07C00 + 0x0000 = 0x07C00

¡La misma dirección física! Es una de las rarezas del modelo segmentado: como cada segmento empieza 16 bytes después del anterior y se solapan, una misma dirección física se puede expresar con muchas parejas segmento:offset distintas (hasta 4.096):

Y ojo con el error típico: si pusiéramos CS=0x7C00 con offset 0, estaríamos en 0x7C000 (507.904 en decimal), unos 465 KB más allá de donde realmente está nuestro código.

Esta ambigüedad tiene consecuencias directas en el código. La directiva [ORG 0x7C00] le dice a nasm que calcule todas las direcciones (etiquetas, cadenas...) como offsets a partir de 0x7C00, lo que solo es correcto si el segmento vale 0. Si la BIOS nos entrega CS=0x07C0, un salto absoluto a una de nuestras etiquetas iría a parar a 0x07C0:0x7Cxx, es decir, a la dirección física 0xF8xx, donde no hay nada nuestro. Por eso un bootloader serio no se fía y fija los segmentos él mismo, como haremos en un momento.

Una última curiosidad que dará juego más adelante: ¿cuál es la dirección más alta que podemos formar? 0xFFFF:0xFFFF = 0xFFFF0 + 0xFFFF = 0x10FFEF... ¡por encima de 1 MB! En el 8086, con solo 20 líneas de dirección, ese bit extra se perdía y la dirección "daba la vuelta" hasta 0x0FFEF. En procesadores posteriores aparece la famosa línea A20 y, con ella, casi 64 KB extra de memoria accesibles desde Modo Real (la HMA, High Memory Area). Pero esa historia, con sus trucos de compatibilidad, la dejamos para otro momento.

Con esto ya sabemos dónde vive nuestro código y cómo direccionarlo. Ahora toca lo divertido: que el bootloader haga algo que se vea en pantalla.

Leer más