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 :
- 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:
- 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.