assembly x86 nasm bootloader real-mode

assembly - Las tablas cercanas de llamada/salto no siempre funcionan en un gestor de arranque



x86 nasm (1)

El problema

La respuesta a su pregunta está enterrada en su pregunta, simplemente no es obvia. Citó mis consejos generales para el cargador de arranque :

  1. Cuando el BIOS salta a su código, no puede confiar en que los registros CS , DS , ES , SS , SP tengan valores válidos o esperados. Deben configurarse adecuadamente cuando se inicia el gestor de arranque. Solo se le puede garantizar que su gestor de arranque se cargará y ejecutará desde la dirección física 0x00007c00 y que el número de la unidad de arranque se cargará en el registro DL .

Su código configura correctamente DS y establece su propia pila ( SS y SP ). No copió ciegamente CS a DS , pero lo que hace es confiar en que CS sea ​​un valor esperado (0x0000). Antes de explicar lo que quiero decir con eso, me gustaría llamar su atención sobre una respuesta reciente de Stackoverflow que di sobre cómo funciona la directiva ORG (o el punto de origen especificado por cualquier vinculador) junto con el segmento: par compensado utilizado por el BIOS para saltar a la dirección física 0x07c00.

La respuesta detalla cómo la copia CS a DS puede causar problemas al hacer referencia a direcciones de memoria (variables, por ejemplo). En el resumen dije:

No asuma que CS es un valor que esperamos, y no copie ciegamente CS a DS. Establecer DS explícitamente.

La clave es no asumir que CS es un valor que esperamos . Entonces, su próxima pregunta puede ser: parece que no estoy usando CS, ¿verdad? La respuesta es sí. Normalmente, cuando usa una instrucción CALL o JMP típica, se ve así:

call print_char jmp somewhereelse

En el código de 16 bits, ambos son saltos relativos. Esto significa que saltas hacia adelante o hacia atrás en la memoria, pero como un desplazamiento relativo a la instrucción justo después de JMP o CALL . La ubicación de su código dentro de un segmento no importa, ya que es un desplazamiento más / menos desde donde se encuentra actualmente. Cuál es el valor actual de CS en realidad no importa con saltos relativos, por lo que deberían funcionar como se esperaba.

Su ejemplo de instrucciones que no siempre parecen funcionar correctamente incluye:

call [call_tbl] ; Call print_char using near indirect absolute call ; via memory operand call [ds:call_tbl] ; Call print_char using near indirect absolute call ; via memory operand w/segment override call near [si] ; Call print_char using near indirect absolute call ; via register

Todos estos tienen una cosa en común. Las direcciones que son CALL ed o JMP ed son ABSOLUTAS , no relativas. El desplazamiento de la etiqueta estará influenciado por el ORG (punto de origen del código). Si observamos un desmontaje de su código, veremos esto:

objdump -mi8086 -Mintel -D -b binary boot.bin --adjust-vma 0x7c00 boot.bin: file format binary Disassembly of section .data: 00007c00 <.data>: 7c00: 31 c0 xor ax,ax 7c02: 8e d8 mov ds,ax 7c04: fa cli 7c05: 8e d0 mov ss,ax 7c07: bc 00 7c mov sp,0x7c00 7c0a: fb sti 7c0b: be 34 7c mov si,0x7c34 7c0e: a0 36 7c mov al,ds:0x7c36 7c11: e8 18 00 call 0x7c2c ; Relative call works 7c14: a0 37 7c mov al,ds:0x7c37 7c17: ff 16 34 7c call WORD PTR ds:0x7c34 ; Near/Indirect/Absolute call 7c1b: 3e ff 16 34 7c call WORD PTR ds:0x7c34 ; Near/Indirect/Absolute call 7c20: ff 14 call WORD PTR [si] ; Near/Indirect/Absolute call 7c22: a0 38 7c mov al,ds:0x7c38 7c25: e8 04 00 call 0x7c2c ; Relative call works 7c28: fa cli 7c29: f4 hlt 7c2a: eb fd jmp 0x7c29 7c2c: b4 0e mov ah,0xe ; Beginning of print_char 7c2e: bb 00 00 mov bx,0x0 ; function 7c31: cd 10 int 0x10 7c33: c3 ret 7c34: 2c 7c sub al,0x7c ; 0x7c2c offset of print_char ; Only entry in call_tbl 7c36: 42 inc dx ; 0x42 = ASCII ''B'' 7c37: 4d dec bp ; 0x4D = ASCII ''M'' 7c38: 45 inc bp ; 0x45 = ASCII ''E'' ... 7dfd: 00 55 aa add BYTE PTR [di-0x56],dl

Agregué manualmente algunos comentarios donde se encuentran las declaraciones CALL , incluidas las relativas que funcionan y las cercanas / indirectas / absolutas. También he identificado dónde está la función print_char y dónde estaba en call_tbl .

Desde el área de datos después del código, vemos que call_tbl está en 0x7c34 y contiene un desplazamiento absoluto de 2 bytes de 0x7c2c. Todo esto es correcto, pero cuando utiliza un desplazamiento absoluto de 2 bytes, se supone que está en el CS actual. Si ha leído esta respuesta de Stackoverflow (que mencioné anteriormente) sobre lo que sucede cuando se utiliza el DS y el desplazamiento incorrectos para hacer referencia a una variable, ahora puede darse cuenta de que esto puede aplicarse a las LLAMADAS de JMP que usan compensaciones absolutas que involucran NEAR 2- byte valores absolutos.

Como ejemplo, tomemos esta llamada que no siempre funciona:

call [call_tbl]

call_tbl se carga desde DS: [call_tbl]. Configuramos DS correctamente a 0x0000 cuando iniciamos el gestor de arranque para que esto recupere correctamente el valor 0x7c2c de la dirección de memoria 0x0000: 0x7c34. Luego, el procesador establecerá IP = 0x7c2c PERO asume que es relativo al CS configurado actualmente. Como no podemos suponer que CS es un valor esperado, el procesador potencialmente puede LLAMAR o JMP a la ubicación incorrecta. Todo depende de qué CS: IP usó el BIOS para saltar a nuestro gestor de arranque (puede variar).

En el caso en que el BIOS haga el equivalente de un FAR JMP a nuestro gestor de arranque en 0x0000: 0x7c00, CS se establecerá en 0x0000 e IP en 0x7c00. Cuando encontremos la call [call_tbl] se habría resuelto una LLAMADA a CS: IP = 0x0000: 0x7c2c. Esta es la dirección física (0x0000 << 4) + 0x7c2c = 0x07c2c, que es de hecho donde la función print_char en la memoria en la que la función comienza físicamente.

Algunas BIOS hacen el equivalente de un FAR JMP a nuestro gestor de arranque en 0x07c0: 0x0000, CS se establecerá en 0x07c0 e IP en 0x0000. Esto también se asigna a la dirección física (0x07c0 << 4) + 0 = 0x07c00. Cuando encontremos la call [call_tbl] se habría resuelto una LLAMADA a CS: IP = 0x07c0: 0x7c2c. Esta es la dirección física (0x07c0 << 4) + 0x7c2e = 0x0f82c. Esto es claramente incorrecto ya que la función print_char está en la dirección física 0x07c2c, no 0x0f82c.

Tener un CS configurado incorrectamente causará problemas para las instrucciones JMP y CALL que hacen direccionamiento cercano / absoluto. Además, cualquier operando de memoria que use una anulación de segmento de CS: Un ejemplo del uso de CS: anulación en un controlador de interrupción en modo real se puede encontrar en esta respuesta de Stackoverflow

Solución

Como se ha demostrado que no podemos confiar en CS que se configura cuando el BIOS salta a nuestro código, podemos configurar CS nosotros mismos. Para configurar CS , podemos hacer un FAR JMP en nuestro propio código que establecerá CS: IP en valores que tengan sentido para el ORG (punto de origen del código y los datos) que estamos usando. Un ejemplo de tal salto si usamos ORG 0x7c00:

jmp 0x0000:$+5

$+5 dice que use un desplazamiento que sea 5 por encima de nuestro contador de programa actual. Un jmp lejano tiene una longitud de 5 bytes, por lo que esto tiene el efecto de hacer un salto lejano a la instrucción después de nuestro jmp. También podría haberse codificado de esta manera:

jmp 0x0000:farjmp farjmp:

Cuando se complete cualquiera de estas instrucciones, CS se establecerá en 0x0000 e IP se establecerá en el desplazamiento de la siguiente instrucción. Lo clave para nosotros es que CS será 0x0000. Cuando se combina con un ORG de 0x7c00, resolverá correctamente las direcciones absolutas para que funcionen correctamente cuando se ejecutan físicamente en la CPU. 0x0000: 0x7c00 = (0x0000 << 4) + 0x7c00 = dirección física 0x07c00.

Por supuesto, si usamos ORG 0x0000, entonces necesitamos establecer CS en 0x07c0. Esto es porque (0x07c0 << 4) + 0x0000 = 0x07c00. Entonces podríamos codificar el jmp lejano de esta manera:

jmp 0x07c0:$+5

CS se establecerá en 0x07c0 e IP se establecerá en el desplazamiento de la siguiente instrucción.

El resultado final de todo esto es que estamos configurando CS en el segmento que queremos, y no confiamos en un valor que no podemos garantizar cuando el BIOS termina de saltar a nuestro código.

Problemas con diferentes entornos

Como hemos visto, el CS puede importar. La mayoría de las BIOS, ya sea en un emulador, una máquina virtual o un hardware real, hacen el equivalente de un salto lejano a 0x0000: 0x7c00 y en esos entornos su gestor de arranque habría funcionado. Algunos entornos, como AMI Bioses y Bochs 2.6 anteriores, al iniciar desde un CD, están iniciando nuestro gestor de arranque con CS: IP = 0x07c0: 0x0000. Como se discutió en esos entornos, las llamadas CALL y JMP cercanas / absolutas procederán a ejecutarse desde ubicaciones de memoria incorrectas y provocarán que nuestro gestor de arranque funcione incorrectamente.

Entonces, ¿qué pasa con Bochs trabajando para una imagen de disquete y no para una imagen ISO ? Esta es una peculiaridad en versiones anteriores de Bochs . Al arrancar desde un disquete, el BIOS virtual salta a 0x0000: 0x7c00 y cuando arranca desde una imagen ISO se usa 0x07c0: 0x0000. Esto explica por qué funciona de manera diferente. Este comportamiento extraño aparentemente se produjo debido a la interpretación literal de una de las especificaciones de El Torito que menciona específicamente el segmento 0x07c0. Las versiones más nuevas de los BIOS virtuales de Boch se modificaron para usar 0x0000: 0x7c00 para ambos.

¿Esto significa que algunas BIOS tienen un error?

La respuesta a esta pregunta es subjetiva. En las primeras versiones de PC-DOS de IBM (antes de 2.1), el gestor de arranque asumió que el BIOS saltó a 0x0000: 0x7c00, pero esto no estaba claramente definido. Algunos fabricantes de BIOS en los años 80 comenzaron a usar 0x07c0: 0x0000 y rompieron algunas versiones tempranas de DOS . Cuando se descubrió esto, los cargadores de arranque se modificaron para que se comportaran bien y no hicieran suposiciones sobre qué segmento: el par de desplazamiento se usó para alcanzar la dirección física 0x07c00. En ese momento, uno podría haber considerado esto como un error, pero se basó en las ambigüedades introducidas con el segmento de 20 bits: pares de desplazamiento.

Desde mediados de los años 80, es mi opinión que cualquier nuevo gestor de arranque que asume que CS es un valor específico ha sido codificado por error.

Problema general

He estado desarrollando un simple gestor de arranque y me he encontrado con un problema en algunos entornos donde las instrucciones como estas no funcionan:

mov si, call_tbl ; SI=Call table pointer call [call_tbl] ; Call print_char using near indirect absolute call ; via memory operand call [ds:call_tbl] ; Call print_char using near indirect absolute call ; via memory operand w/segment override call near [si] ; Call print_char using near indirect absolute call ; via register

Cada uno de estos implica una CALL indirecta cercana a compensaciones de memoria absolutas. He descubierto que tengo problemas si uso tablas JMP similares. Las llamadas y saltos que son relativos no parecen verse afectados. Código como este funciona:

call print_char

He tomado el consejo presentado en Stackoverflow por carteles que discuten lo que se debe y no se debe hacer al escribir un gestor de arranque. En particular, vi esta respuesta de Stackoverflow con consejos generales para el cargador de arranque . El primer consejo fue:

  1. Cuando el BIOS salta a su código, no puede confiar en que los registros CS , DS , ES , SS , SP tengan valores válidos o esperados. Deben configurarse adecuadamente cuando se inicia el gestor de arranque. Solo se puede garantizar que su gestor de arranque se cargará y ejecutará desde la dirección física 0x07c00 y que el número de la unidad de arranque se cargará en el registro DL .

Tomando todos los consejos, no confié en CS , configuré una pila y configuré DS para que sea apropiado para el ORG (desplazamiento de origen) que usé. He creado un ejemplo mínimo completo verificable que demuestra el problema. Construí esto usando NASM , pero no parece ser un problema específico de NASM .

Ejemplo mínimo

El código para probar es el siguiente:

[ORG 0x7c00] [Bits 16] section .text main: xor ax, ax mov ds, ax ; DS=0x0000 since OFFSET=0x7c00 cli ; Turn off interrupts for potentially buggy 8088 mov ss, ax mov sp, 0x7c00 ; SS:SP = Stack just below 0x7c00 sti ; Turn interrupts back on mov si, call_tbl ; SI=Call table pointer mov al, [char_arr] ; First char to print ''B'' (beginning) call print_char ; Call print_char directly (relative jump) mov al, [char_arr+1] ; Character to print ''M'' (middle) call [call_tbl] ; Call print_char using near indirect absolute call ; via memory operand call [ds:call_tbl] ; Call print_char using near indirect absolute call ; via memory operand w/segment override call near [si] ; Call print_char using near indirect absolute call ; via register mov al, [char_arr+2] ; Third char to print ''E'' (end) call print_char ; Call print_char directly (relative jump) end: cli .endloop: hlt ; Halt processor jmp .endloop print_char: mov ah, 0x0e ; Write CHAR/Attrib as TTY mov bx, 0x00 ; Page 0 int 0x10 retn ; Near call address table with one entry call_tbl: dw print_char ; Simple array of characters char_arr: db ''BME'' ; Bootsector padding times 510-($-$$) db 0 dw 0xAA55

Construyo una imagen ISO y una imagen de disquete de 1,44 MB para fines de prueba. Estoy usando un entorno Debian Jessie pero la mayoría de las distribuciones de Linux serían similares:

nasm -f bin boot.asm -o boot.bin dd if=/dev/zero of=floppy.img bs=1024 count=1440 dd if=boot.bin of=floppy.img conv=notrunc mkdir iso cp floppy.img iso/ genisoimage -quiet -V ''MYBOOT'' -input-charset iso8859-1 -o myos.iso -b floppy.img -hide floppy.img iso

floppy.img con una imagen de disquete llamada floppy.img y una imagen ISO llamada myos.iso .

Expectativas vs resultados reales

En la mayoría de las condiciones, este código funciona, pero en varios entornos no lo hace. Cuando funciona, simplemente imprime esto en la pantalla:

BMMME

Imprimo B usando una LLAMADA típica con un desplazamiento relativo que parece funcionar bien. En algunos entornos, cuando ejecuto el código, acabo de recibir:

si

Y luego parece que simplemente deja de hacer cualquier cosa. Parece imprimir la B correctamente, pero luego sucede algo inesperado.

Ambientes que parecen funcionar:

  • QEMU arrancado con disquete e ISO
  • VirtualBox arrancado con disquete e ISO
  • VMWare 9 arrancado con disquete e ISO
  • DosBox arrancado con disquete
  • Bochs (2.6) oficialmente empaquetado en Debian Jessie usando imagen de disquete
  • Bochs 2.6.6 (construido a partir del control de fuente) en Debian Jessie usando una imagen de disquete e imagen ISO
  • Sistema AST Premmia SMP P90 de mediados de los 90 con disquete e ISO

Entornos que no funcionan como se esperaba:

  • Bochs oficialmente empaquetado (2.6) en Debian Jessie usando imagen ISO
  • Sistema basado en 486DX con BIOS AMI de principios de los 90 que usa la imagen de disquete. Los CD no se iniciarán en este sistema, por lo que no se pudo probar el CD.

Lo que me parece interesante es que Bochs (versión 2.6) no funciona como se esperaba en Debian Jessie usando un ISO . Cuando inicio desde el disquete con la misma versión, funciona como se esperaba.

En todos los casos, el ISO y la imagen del disquete parecían cargarse y comenzar a ejecutarse, ya que en TODOS los casos al menos podía imprimir B en la pantalla.

Mis preguntas

  • Cuando falla, ¿por qué solo imprime una B y nada más?
  • ¿Por qué algunos entornos funcionan y otros fallan?
  • ¿Es esto un error en mi código o en el hardware / BIOS?
  • ¿Cómo puedo solucionarlo para poder seguir usando tablas de salto y llamada casi indirectas para compensaciones de memoria absolutas? Soy consciente de que puedo evitar estas instrucciones por completo y eso parece resolver mi problema, pero me gustaría poder entender cómo y si puedo usarlas correctamente en un gestor de arranque.