Última actualización 27-12-2002
Aprenda ensamblador 80x86 en dos patadas
ÍNDICE:
INTRODUCCIÓN
Este documento de tan optimista título pretende ser una breve introducción al ensamblador; la idea es simplemente coger un poco de práctica con el juego de instrucciones de la familia 80x86, direccionamiento, etc.. sin entrar demasiado en la estructura de los ejecutables, modelos de memoria... Está pensado fundamentalmente para gente que ya sepa cómo funciona el motorola 68000 (por lo que me saltaré gran parte del rollo que suele ser previo a este tipo de textos) y a menudo me referiré a él para compararlo con la familia Intel. Tampoco se tome esto por la panacea, no son más que algunas cosillas. Si alguien luego coge vicio y quiere saber más que vaya a la sección de enlaces. Ahí tendrá material de sobra por una buena temporada. Si en algún momento me explico menos de lo que hace falta sobre algo, ya sabes adónde ir.
¿Qué vamos a hacer aquí? Comenzaremos programando en MS-DOS, modo real, (16 bits, o sea, segmentos de 64k y gracias), pero sacando algo de partido a las posibilidades del 386 (los registros de 32 bits,por ejemplo); también se aprenderá a usar el coprocesador matemático, porque hacer operaciones en coma flotante a pelo es menos saludable que fumar papel de amianto. A lo largo de las explicaciones se irán comentando las diferencias con las instrucciones de 32 bits para, ya hacia el final, introducir la interacción entre programas en C y ensamblador en un sistema de este tipo.
Intentaré contar más medias verdades que mentiras cuando se complique demasiado el asunto, a fin de no hacer esto demasiado pesado (y así de paso me cubro un poco las espaldas cuando no tenga ni idea del tema, que también pasa) Doy por supuesto que sabes sumar numeros binarios, en hexadecimal, qué es el complemento a 2, etc etc. La notación que usaré será una "b" al final de un número para binario, una "h" para hexadecimal, y nada para decimal -o una "d" donde pueda despistar-, que es lo que usa el ensamblador. Los números hexadecimales que comienzan por una letra requieren un 0 delante, a fin de no confundirlos el ensamblador con etiquetas o instrucciones. Si se me escapa por ahí algún 0 sin venir a cuento, es la fuerza de la costumbre. Por cierto, aunque estrictamente por una "palabra" se entiende el ancho típico del dato del micro (por lo que en un 386 serían 32 bits) me referiré a palabra por 2 bytes, o 16 bits; doble palabra o dword 32; palabra cuádruple o qword 64.
¿Qué te hace falta?
¿Listo todo? Abróchense los cinturones, damas y caballeros, que despegamos.
CAPÍTULO I: BIENVENIDO A LA FAMILIA (INTEL)
Aunque ahora (¿casi?) todo el mundo gasta Pentium xxx o algo de calibre semejante, en el lanzamiento de cada procesador de esta familia se ha guardado siempre compatibilidad con los miembros anteriores. Así, es teóricamente posible ejecutar software de los años 80 en un ordenador actual sin ningún problema (suponiendo que tengamos instalado un sistema operativo que lo apoye).
Todo el asunto arranca con el 8086, microprocesador de 16 bits que apareció allá por el año 78; al año siguiente Intel lanzó el 8088, una versión más ecónomica pues contaba con un ancho de bus de 8 bits (lo que le hacía el doble de lento en accesos a memoria de más de 8 bits). Estos micros direccionaban hasta 1Mb de memoria, y en vez de mapear los puertos E/S sobre ella empleaban un direccionamiento específico que abarcaba hasta teóricamente 65536 puertos.
En 1982 se lanzaron los 80186/80188, que en su encapsulado además del procesador propiamente dicho incluían timers, controlador de interrupciones... Incluían algunas instrucciones adicionales para facilitar el trabajo a los compiladores, para acceso a puertos... Hasta donde sé se emplearon como procesadores de E/S en tarjetas de red y similares, más que en ordenadores.
Ese mismo año aparece en escena el 80286, mucho más complejo que el 8086. Aunque el juego de instrucciones es prácticamente el mismo, se diseñó pensando en la ejecución de sistemas multitarea. Contaba con dos modos de ejecución, el real y el protegido. En el modo real el 286 opera como un 8086; sin embargo en modo protegido cuando un programa intenta acceder a una región de la memoria o ejecutar una determinada instrucción se comprueba antes si tiene ese privilegio. Si no es así se activa un mecanismo de protección que generalmente gestionará el sistema operativo, que es el programa que controla los derechos del resto de programas. (Aunque supuso un gran avance en tanto que permitía manejar hasta 16Mb de memoria -con un bus de direcciones de 24 bits- los registros seguían siendo de 16 bits, lo que no permitía manejar bloques de memoria -segmentos- de más de 64k). Por otra parte más importante que el aumento de la velocidad de reloj (hasta 10MHz en el 8086, 16, 20 e incluso 25MHz en el 286) era la reducción del número de ciclos de reloj por instrucción, lo que llevó a un incremento aún mayor del rendimiento.
En 1985 sale el 80386, lo que vendría a ser un peso pesado comparado a sus antecesores. Prácticamente todos los registros quedaban extendidos a 32 bits, e incluía un mecanismo de gestión de memoria más avanzado que el 286, facilitando el uso de memoria virtual (disco duro como si fuera ram). Contaba con un bus de direcciones de 32 bits, llegando a direccionar 4Gb de memoria, y memoria caché. Aparte del modo real incluía un nuevo modo protegido mejorado. En este modo protegido se permitía la ejecución de programas en un modo virtual o V86, posibilitando la ejecución de máquinas virtuales 8086; el sistema puede pasar con cierta facilidad de un modo a otro, permitiendo que funcionen varios "8086" en una especie de modo "real" al tiempo, cada uno con su memoria, estado... funcionando completamente ajeno al resto del software. Cuando se ejecuta un programa de MS-DOS (modo real) bajo Windows (modo protegido) en realidad entra en acción el modo virtual, ejecutando la aplicación con relativa seguridad aislada del sistema y resto de aplicaciones (y digo relativa porque he colgado muchas *muchas* veces el windows enredando con un programilla DOS; los mecanismos de protección del Windows dejan bastante que desear).
Una curiosidad; un 286 en modo protegido NO podía volver al modo real (salvo reseteándolo, claro). Intel se despabiló un poco para el 386 y permitió hacerlo con cierta facilidad (más o menos cambiando un bit). Estas diferencias complicaron horrorosamente la creación de software en modo protegido compatible con ambos micros.. pero bueno, como al 286 pronto se le dio una patada en el culo en ese aspecto no fue tan traumático.
Surgieron después el 386SX (un 386 económico, con bus de direcciones de 24 bits y de datos de 16), y varios años después el 386SL, una versión del 386 que aprovechaba las nuevas tecnologías de fabricación de circuitos integrados. Éste último se usó sobre todo en equipos portátiles.
El 486 era básicamente un 386 más rápido, en sus versiones SX (sin coprocesador) y DX (con él). Se hicieron famosas las versiones con multiplicador de reloj del micro, DX2 y DX4.
Y en 1993 llega el primero de los celebérrimos Pentium. Se mejoró, como en cada miembro de la familia, el número de ciclos por instrucción; bus de datos de 64 bits, más caché, más de todo. Luego llegarían los MMX (algunos dicen que MultiMedia eXtensions); un paquete de registros adicionales -bueno, no exactamente- e instrucciones especiales (SIMD, Single Instruction Multiple Data) para manipular vídeo, audio.. Toda la gama Pentium iría añadiendo sus cosillas (AMD por su parte haría lo propio en los suyos), pero sin cambios fundamentales en la arquitectura.
Aunque desde el 8086 la cosa se ha complicado mucho, se podría decir que el microprocesador que supuso el cambio más importante en esta familia fue el 386.
CAPÍTULO II: REPITE CONMIGO. TENGO UN 8086, TENGO 8086..
Nada más empezar toca hacer un acto de fuerza de voluntad, por renunciar a la mayor parte de nuestra memoria RAM, funciones MMX, direccionamiento de 32 bits.. Lo primero que vamos a ver es una descripción de los registros del primer bicho de la familia, el 8086 (aunque un 80286 es básicamente idéntico en este aspecto), pues Intel y los demás fabricantes han puesto cuidado en que todos ellos se puedan comportar como él. Es un micro de 16 bits, y como habrás adivinado sus registros son de 16 bits. Helos aquí:
Registros de datos | AX | BX | CX | DX |
---|---|---|---|---|
Punteros de pila | SP | BP | ||
Registros de índice | DI | SI | ||
Registros de segmento | CS | DS | ES | SS |
Registro de flags |
Los registros de datos, como su nombre indica, contienen generalmente datos. Vienen a ser los D0-D7 del motorola. (Sí, lo sé, no parecen gran cosa, pero es lo que hay) Aunque tienen nombres distintos tienen básicamente la misma funcionalidad, con algunas excepciones. Determinadas operaciones -por ejemplo la multiplicación- exigen que los operandos estén en registros específicos. En ese caso no quedarán más narices que usar esos concretamente.
Cada registro de estos puede ser leído o escrito de manera independiente en su parte alta o baja, como si fueran registros de 8 bits:
Si AX contiene 00FFh y lo incrementamos en 1, AX pasa a valer 0100h (lógicamente) pero si en lugar de incrementar en 1 AX lo hacemos con AL (byte inferior) el resultado es 0000h. (vamos, que cuando se manipula una parte del registro la otra no se ve afectada en absoluto) Por si alguno lo dudaba, H viene de high y L de low.
Para explicar los registros que hacen referencia a memoria hay que contar brevemente qué es la segmentación.
Uno puede pensar que lo lógico y maravilloso para apuntar a una dirección de memoria es colocar dicha dirección en un registro, y ale, que apunte. Eso está muy bien para registros grandes pero da la casualidad de que con 16 bits tenemos 2^16=64k posiciones. Hombre, en aquellos tiempos estaba muy bien para según qué cosas, y aquí tampoco vamos a manejar mucha más memoria, pero tampoco es plan. La solución por la que optó Intel fue usar dos registros de 16 bits (cosa que seguramente ya imaginabas), pero no dispuestos consecutivamente, como podría pensarse:
Segmento: | Desplazamiento: | |
---|---|---|
xxxxxxxxxxxxxxxx | xxxxxxxxxxxxxxxx | <- Dirección de 32 bits |
De esta manera -que, repito, no es la que se usa realmente- se podrían recorrer 2^32 posiciones de memoria. Con el registro llamado de segmento apuntamos al "bloque", esto es, las líneas de mayor peso, y con el de desplazamiento nos movemos dentro de 64k. El problema reside en que si nuestra estructura de datos se encuentra al final de uno de estos bloques (un vector, por ejemplo), y queremos recorrerla linealmente, lo podremos hacer así solamente hasta llegar a la posición FFFFh del segmento apuntado, pues para seguir avanzando es necesario incrementar el registro de segmento en 1 llegados a ese punto (y continuar ya normalmente incrementando el desplazamiento desde 0000h). Resulta que, para más inri, una característica más que deseable -por no decir imprescindible- de un programa es que sea reubicable, es decir, que lo podamos cargar en cualquier zona de la memoria, principalmente porque el sistema operativo nos lo coloca donde puede, y cada vez en un sitio distinto. No hace falta pensar mucho para ver que, por este camino, mal vamos.
Intel dispuso los registros de la siguiente manera:
Segmento: | xxxxxxxxxxxxxxxx0000 |
---|---|
+ | |
Desplazamiento (u offset) | 0000xxxxxxxxxxxxxxxx |
----------------------------------- | |
Dirección de 20 bits | xxxxxxxxxxxxxxxxxxxx |
Así pues para obtener la dirección real se multiplica el registro de segmento por 16, y se suma al de desplazamiento (esto lógicamente no lo tenemos que hacer nosotros, hay un circuitillo por ahí para eso) Existen además parejas segmento-offset que apuntan a la misma posición de memoria (no podría ser de otro modo, si estamos recorriendo 2^20 posiciones con 32 bits). De la manera propuesta ahora, cuando queremos manejar una estructura de no más de 64k (esto nos lo limitan los registros que son de 16 bits, con lo que no hay más vuelta de hoja), podremos apuntar con el registro de desplazamiento al bloque donde lo ubicaremos (alineado en un múltiplo de 16 bytes, claro) y nos moveremos por dentro de este segmento alterando el offset. Umm, por si alguien se está preguntando si tenemos el mismo problema que con el 68K de tener que referirnos a direcciones pares para acceder a palabras (16 bits), la respuesta es no. A ese respecto tenemos completa libertad, si bien es cierto que -por lo menos en los primeros de la familia, que yo sepa- los accesos a posiciones impares son más lentos.
Recordemos además que todo procesador de esta familia que funcione en modo real (o virtual) se comporta como un 8086 más o menos rápido en cuanto a direccionamiento de memoria, por lo que bajo MSDOS siempre estaremos limitados de esta manera.
De la suma de segmento y offset puede suceder que, para segmentos altos, exista un bit de carry a 1. Los 286 y superiores pueden aprovechar esto para acceder a la llamada "memoria alta", 64k situados por encima de los 2^20=1024k iniciales. No será nuestro caso, pues nos limitaremos a manejar la memoria a la vieja usanza. De hecho únicamente poblaremos los primeros 640k (la famosa memoria convencional) pues en las posiciones de los segmentos A000h y superiores se mapea la memoria de vídeo, la expandida... Pero bueno, de momento con 640k vamos sobrados.
Ahora casi todos los registros cobran ya sentido; los registros de segmento apuntan al segmento, y los de índice indican el desplazamiento dentro del segmento. Los registros de índice se pueden usar como registros de datos sin problemas para sumas, movimiento de datos... no así los de segmento, que tienen fuertes limitaciones. Cuando se diga "un registro" como operando de una instrucción, eso incluye en principio cualquier registro menos los de segmento.
El par CS:IP indica la dirección de memoria donde está la instrucción que se ejecuta. Los nombres vienen de Code Segment e Instruction Pointer. A esta parejita sólo la modifican las instrucciones de salto, así que podemos olvidarnos -un poco- de ella.
SS:SP apuntan a la pila. Como en todo micro que se precie, tenemos una pila adonde echar los valores de los registros que queremos preservar para luego, las direcciones de retorno de las subrutinas.. La pila crece hacia abajo, es decir, cuando metemos algo en la pila el puntero de pila se decrementa, y cuando lo sacamos se incrementa. Siempre se meten valores de 16 bits. Significan Stack Segment y Stack Pointer, claro. Por lo general el SS no se toca, y el SP quizá pero poquito, ya que hay instrucciones específicas para manejar la pila que alteran SP indirectamente. Digamos que uno dice "tiro esto a la pila" o "saco lo primero que haya en la pila y lo meto aquí" y el micro modifica SP en consecuencia. BP es un puntero Base, para indicar también desplazamiento, que se usa en algunos modos de direccionamiento y especialmente cuando se manejan subrutinas. Con él haremos una chapuza parecida a la de la instrucción LINK del 68K. Ya lo veremos todo con más calma a su debido tiempo.
DS y ES son registros de segmento adicionales, el primero llamado de datos (Data) y el segundo Extra. Con ellos apuntaremos a los segmentos donde tengamos nuestros datos (ya que del código se encarga CS). Estos sí que los manipularemos, y mucho.
DI y SI son registros índice, es decir, sirven para indicar el offset dentro de un segmento. Acabarás harto de tanto usarlos. En las instrucciones de cadena DI se asocia por defecto a DS, y SI a ES.
Aunque el sistema de segmentación pueda parecer muy engorroso (bueno, es que a este nivel realmente lo es, y mucho) en realidad es vital. El modo protegido del 386+ (386+ por "386 y superiores") emplea segmentación, pero aprovechando 32 bits. En entornos multitarea hay que estructurar cada programa con su(s) bloque(s) de código y de memoria, cada uno con su nivel de acceso (por ejemplo si estás guarreando con la memoria te interesa que sólo enredes con la de tu programa, para no sobreescribir otros procesos en curso por accidente, o parte del sistema operativo incluso); lo que sucede es que ahora es una mierda porque esta segmentación es muy limitada. Sirva esto para evitar la cantinela segmentación=mala que le puede haber pasado a uno por la cabeza a estas alturas de película. Tras este pequeño paréntesis, sigamos.
El registro de flags está formado por varios bits cada uno con significado propio, que son modificados por las operaciones que realizamos:
Bit | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Flag | -- | -- | -- | -- | OF | DF | IF | TF | SF | ZF | -- | AF | -- | PF | -- | CF |
Aunque es un registro de 16 bits sólo algunos de ellos tienen significado. Los otros adquieren valores indeterminados, y se dice que son bits reservados; un programa no debe tocarlos, pues aunque un equipo dado puede que no los use, otros micros podrían manipularlos internamente, con resultados impredecibles.
No conviene aturullarse demasiado a cuenta de los flags, pues a medida que se vayan viendo las instrucciones que los usen se irán comentando con más calma.
- Bueno, vale, tengo un 386
Aunque no nos vamos a meter con las virguerías que introdujo el 386 (que no fueron pocas, pues un Pentium es básicamente un 386 con muchas pijaditas, pero que funciona más o menos igual), usaremos algunas de sus ventajas (fundamentalmente registros más gordos). Ahora los registros de datos son EAX,EBX,ECX,EDX funcionando de manera idéntica a los anteriores, sólo que ahora son de 32 bits. Si queremos acceder a la parte baja de 16 bits, nos referiremos normalmente a AX,BX.. y si queremos hacer lo propio con las dos partes que componen la parte baja de 16 bits, hablaremos de AH,AL,BH.. todo exactamente igual. Se añaden además registros de segmento adicionales (de 16 bits, como los otros), FS y GS. De igual manera se extienden a 32 bits BP (EBP), DI (EDI) y SI (ESI). Llamando a unos u otros modificaremos 16 o 32 bits. Como vamos a manejar segmentos de 64k (modo real), esto último no nos preocupa demasiado (por el momento). Con saber que tenemos registros de datos más gordos y dos más de segmento, suficiente.
¿Alguien se perdió? ¿No? Pues más vale, porque empezamos a programar..
CAPITULO III. EL MOVIMIENTO SE DEMUESTRA MOVIENDO.
La instrucción MOV y los modos de direccionamiento.
He aquí nuestra primera instrucción:
MOV destino,origen
Efectivamente, sirve para mover. Lo que hace es copiar lo que haya en "origen" en "destino". Lo de que primero vaya el destino y luego el origen es común a todas las instrucciones del 8086 que tengan dos operandos, lo cual creará más de un quebradero de cabeza a los entusiastas del motorola (a mí me pasó lo mismo pero al revés, así que ajo y agua, chavales). No hace falta decir (bueno, por si acaso lo digo) que destino y origen tienen que tener el mismo tamaño; así
MOV AX,BL
haría pitar al ensamblador como loco, y con toda la razón.
Si quisiéramos poner a 0 el registro DX podríamos poner
MOV DX,0
muy distinto de
MOV DX,[0]
Los corchetes significan "lo que haya en la dirección..". En este caso el micro cogería la palabra (16 bits, pues es el tamaño del operando destino, así que queda implícito) que estuviera en la dirección 0 y la copiaría en DX. Más aún. No se trata de la dirección 0 sino del offset 0; ¿de qué segmento? DS, que es el registro de segmento por defecto. Si quisiéramos copiar a DX la primera pareja de bytes del segmento apuntado por ES, porque es allí donde tenemos el dato, tendríamos que poner un prefijo de segmento (o segment override, para los guiris):
MOV DX,ES:[0]
Esto va al segmento ES, se desplaza 0 bytes, coge los primeros 2 bytes a partir de esa dirección y los guarda en DX. ¿Complicado? Pues no hemos hecho más que empezar. Veamos los modos de direccionamiento que permite este micro, sin más que poner el offset dentro de []:
Nombre | Offset | Segmento por defecto |
---|---|---|
Absoluto | Valor inmediato | DS |
Indirecto con base | BX+x | DS |
BP+x | SS | |
Indirecto con índice | DI+x | DS |
SI+x | DS | |
Ind. con base e índice | BX+DI+x | DS |
BX+SI+x | DS | |
BP+DI+x | SS | |
BP+SI+x | SS |
Por x queremos decir un número en complemento a dos de 8 o 16 bits
Supongamos que tenemos una cadena de caracteres en el segmento ES, offset 100h, y queremos mover un carácter cuya posición viene indicada por el registro BP, a AL. El offset del carácter sería BP+100h; como el segmento por defecto para ese modo de direccionamiento es SS, es necesario un prefijo de segmento. La instrucción sería:
MOV AL,ES:[BP+100h]
Observando un poco la tabla podemos darnos cuenta de que todos emplean por defecto el registro de segmento DS excepto los que usan BP, que se refieren a SS. En general no es buena idea usar prefijos de segmento, pues las instrucciones que los usan se codifican en más bytes y por tanto son más lentas. Así si hemos de referirnos a DS usaremos otros registros distintos de BP siempre que sea posible. Por cierto, si usamos el registro de segmento por defecto como prefijo, la instrucción que se codifique al ensamblar será exactamente la misma.
Comprendidos bien los modos de direccionamiento del 8086, voy a añadir algunos más. Los que permiten los 386+. Cuando se emplean en direccionamientos indirectos registros de 32 bits es posible usar cualquiera de ellos. Así "MOV EAX,[ECX]" sería perfectamente válido. Y más aún (y esto es muy importante para manipular arrays de elementos mayores que un byte), el registro "índice" puede ir multiplicado por 2,4 u 8 si de desea: es posible realizar operaciones del tipo "MOV CL,[EBX+EDX*8]". Al que todo esto le parezca pequeños detallitos en vez de potentes modos de direccionamiento, que se dedique a la calceta porque esto no es lo suyo.
No hay que olvidar por otra parte que aunque estemos usando registros de 32 bits, seguimos limitados a segmentos de 64k si programamos bajo MSDOS. Bajo esta circunstancia, cuando se emplee un direccionamiento de este tipo hay que asegurarse de que la dirección efectiva no supera el valor 0FFFFh. Si no se tiene cuidado, el programa casca. De todos modos la intención es abandonar el MSDOS al acabar el tutorial, así que no suframos demasiado por esto.
La instrucción MOV tiene ciertas limitaciones. No admite cualquier pareja de operandos. Sin embargo esto obedece a reglas muy sencillas:
Las dos primeras reglas obligan a pasar por un registro intermedio de datos en caso de tener que hacer esas operaciones. La tercera es bastante obvia, pues si se pudiera hacer eso estaríamos haciendo que el contador de instrucción apuntase a sabe Dios dónde.
Otra regla fundamental de otro tipo -y que también da sus dolores de cabeza- es que cuando se mueven datos de o hacia memoria se sigue la "ordenación Intel", que no es más que una manera de tocar las narices: los datos se guardan al revés. Me explico. Si yo hiciera
MOV AX,1234h
MOV DS:[0],AX
uno podría pensar que ahora la posición DS:0 contiene 12h y DS:1 34h. Pues no. Es exactamente al revés. Cuando se almacena algo en memoria, se copia a la posición señalada la parte baja y luego, en la posición siguiente, la parte alta. Lo gracioso del asunto (y más que nada porque si no fuera así Intel tocaría bastante más abajo de las narices) es que cuando se trae un dato de memoria a registros se repite la jugada, de tal manera que al repetir el movimiento en sentido contrario, tenga en el registro el mismo dato que tenía al principio. Pero la cosa no se detiene ahí. Tras
MOV EAX,12345678h
MOV ES:[0124h],EAX
la memoria contendría algo así como:
Segmento | Offset: | Contenido: |
---|---|---|
ES | 0124h | 78h |
0125h | 56h | |
0126h | 34h | |
0127h | 12h |
Vamos, que lo que digo para palabras lo digo para palabras dobles. ¿Divertido, eh?
Uno se puede preguntar además: ¿por qué no hacer "MOV ES:[0124h],12345678h"? Se puede, claro, pero no así (bueno, en este caso concreto tal vez, pero como norma general,no). El ensamblador no puede saber el tamaño que tiene el operando inmediato, así que no sabe cuantos bytes tiene que escribir. Hay que especificar si es un byte, un word, o double word con lo que se conocen como typecast:
MOV BYTE PTR [DI],0h ;DI es un puntero a byte
MOV WORD PTR [DI],0 ; puntero a word
MOV DWORD PTR [DI],0h ; puntero a double word
Cada una de ellas pone a cero 1,2 ó 4 bytes a partir de la dirección apuntada por DS:DI. Una última nota a cuento de estas cosas. Ante la instrucción "MOV AX,[0]", aunque realmente es correcta, el ensamblador dará un error. Aunque cuando se usan variables o índices no sucede esto, cuando son direcciones inmediatas es necesario especificar el segmento, aunque sea el que usa por defecto el micro. Esto es cuestión del ensamblador, no del micro. Si se especifica por ejemplo "MOV AX,DS:[0]" la instrucción se codificará sin el prefijo de segmento, pues no hace falta al ser el registro por defecto. Esto es común a todas las instrucciones que empleen este modo de direccionamiento, y aunque es raro indicar al ensamblador direcciones de memoria mediante valores inmediatos, conviene tenerlo en cuenta.
Bueno, después de toda esta parafernalia vamos a hacer nuestro primer programa que ya va siendo hora. Con él aprovecharemos para ver un poco la sintaxis del ensamblador, uso de etiquetas..
.386 .MODEL SMALL ;segmento de pila .STACK 100h ;segmento de datos .DATA Texto DB 'Que pasa, mundo','0' ;cadena que se va a imprimir ;segmento de codigo .CODE Inicio PROC ;inicio del procedimiento Inicio mov ax,@DATA mov ds,ax ;apunta con DS al segmento de datos mov ax,0B800h ;apunta con ES al segmento de la memoria de vídeo mov es,ax mov di,offset Texto ;con DI se recorrerá la cadena mov si,0 ;con SI la memoria de vídeo ciclo: mov al,[di] ;mueve DS:[DI] a AL cmp al,0 ;compara AL con 0 je fin ;si son iguales salta a "fin" mov es:[si],al ;copia la letra que toca a la posición de la pantalla indicada mov byte ptr es:[si+1],00010010b ;y añade el atributo correspondiente add di,1 ;incrementa en 1 DI add si,2 ;incrementa en 2 SI jmp ciclo ;salta a "ciclo" fin: mov ax,4c00h ;función del sistema operativo: ha finalizado el programa int 21h ;llamada al MSDOS Inicio ENDP ;fin del procedimiento inicio END Inicio ;fin del programa, y punto donde empezará la ejecución
Si has conseguido ensamblarlo y ejecutarlo (enhorabuena), habrás comprobado que, efectivamente, se trata de una colorista versión libre del celebérrimo "hola mundo".
Aunque se han introducido 5 instrucciones nuevas, hemos conseguido hacer prácticamente todo el código con instrucciones de movimiento. Existen maneras más sencillas de imprimir una cadena en la pantalla, pero he optado por esta para usar varios modos de direccionamiento, y demostrar que la instrucción MOV tiene mucho que decir. Por cierto, tanto da escribir las instrucciones en mayúscula que minúscula. Bueno, antes de ver cómo funciona el programa veamos cómo está estructurado.
La primera directiva indica que el procesador que vamos a usar es un 386 o superior. El programa se divide en 3 segmentos, uno para la pila, uno para el código y otro para los datos. Se han empleado directivas simplificadas para definir los segmentos, a fin de hacer más sencillos los programas. Esto quiere decir que existen definiciones alternativas para los segmentos más detalladas pero levemente más complejas. ".MODEL SMALL" indica que usaremos un segmento para datos y otro para código (nunca hay más de un segmento de pila). Después de todo no vamos a necesitar más de 64k para ninguno de los 3 segmentos (al menos por el momento). 100h después de ".STACK" indica que el segmento de pila ocupará 256 bytes. Con ".DATA" y ".CODE" definimos dónde empieza el segmento de datos y el de código respectivamente.
DB significa "Define Byte", es decir, que interprete lo que sigue como datos en formato byte. Lo que hay entre comillas simples es tratado por el ensamblador como el valor numérico del byte que encierran, es decir, su código ASCII. En este caso almacena una cadena ASCII seguida de un 0 que usaremos como marcador del fin de la cadena.
Dentro del segmento de código hemos definido un procedimiento. La definición de un procedimiento es
Nombre PROC
Nombre ENDP
Un procedimiento es un bloque funcional; un módulo o una subrutina.
La estructura habitual de una línea de lenguaje ensamblador es la siguiente:
[etiqueta] instrucción [operandos] [comentario]
Entre corchetes se indican los campos que pueden no estar en una línea. La etiqueta llevará dos puntos si se trata de una etiqueta cercana o near (sólo se saltará a ella desde el mismo segmento donde está), y no los llevará si es lejana o far. Por ejemplo, un salto a una etiqueta cercana modifica sólo IP, mientras que a una lejana tanto IP como CS. Si las etiquetas han de contener un punto, éste irá al inicio. No hay ningún problema en "pegar" las instrucciones al inicio de la línea si no se desea etiqueta, motivo por el cual no se pueden emplear palabras reservadas como nombres de etiqueta.
Los comentarios se indican con ";". Todo lo que haya después de ";" en una línea será ignorado por el ensamblador.
END indica que el código ha acabado, e indica además adónde apuntará CS:IP cuando arranque el programa. Puede parecer una obviedad, pero es común que haya varios procedimientos y no sea necesariamente el primero el del programa principal. En nuestro caso comenzará en "Inicio"
¿Qué debe hacer el programa? Cuando la tarjeta de vídeo está configurada en modo texto (80x25 líneas, sin florituras, que es lo que hace el MSDOS por defecto) la información de la pantalla está almacenada en una región de la memoria, en forma de array de parejas de bytes. El primer byte corresponde al código ASCII mientras que el segundo es su atributo; cada pareja corresponde a un carácter de la pantalla comenzando desde la esquina superior izquierda hacia la derecha, y de arriba hacia abajo (ordenación por filas). La idea es copiar la cadena de texto del segmento de datos a la memoria de vídeo, para que aparezca en pantalla. Salvo circunstancias muy especiales (adaptadores de vídeo monocromos, que la página de vídeo activa no sea la 0; ambas cosas rarísimas) esto debería bastar. El atributo no es más que el color de fondo y de línea de la letra.
¿Cómo lo hace? El sistema operativo inicializa automáticamente CS y SS en los segmentos correspondientes cuando carga el ejecutable, tras haber ubicado todo el código en memoria. Crea también el llamado PSP (Program Segment Prefix) que contiene información del programa (como por ejemplo la dirección de retorno cuando termine su ejecución), y a él apuntan inicialmente DS y ES. Salvo los registros mencionados, su valor inicial está completamente indeterminado. Los valores de todos los demás registros están indeterminados.
Nuestra primera tarea por tanto es apuntar al segmento de datos y al segmento donde se encuentra la memoria de vídeo. El primero no sabemos dónde queda porque el sistema operativo nos lo pone donde le da la gana, así que con @DATA decimos "dondequiera que nos haya metido el segmento de datos"; el segundo es una posición fija, 0B800h. Una vez cargados los segmentos tenemos que ir copiando cada byte de la cadena de texto en el sitio correspondiente de la pantalla (hemos optado por la opción más sencilla: al inicio de la pantalla). DI tendrá el offset del inicio del texto. Cuando tenemos que cargar un registro de índice con un offset no tenemos que calcular "a mano" la posición de memoria que ocupa dentro del segmento. Con incluir la palabra clave "offset" delante de la etiqueta al referirnos a ella es suficiente. En este caso tanto daría poner "offset Texto" que 0, pero en un programa normal, a pocas líneas que tenga, es un engorro hacerlo así. SI lo cargamos directamente a 0 pues, esto lo sabemos seguro, el contenido de la pantalla empieza en la posición 0 del segmento de vídeo.
Ahora tenemos un ciclo; coge una letra del texto, lo copia a la memoria de vídeo, avanza una posición. Si el byte del carácter del texto es un 0 significa que hemos terminado; en ese caso salta a la etiqueta "fin". Si no es así, tras copiarla incrementa DI en una posición y SI en dos (pues cada carácter del vídeo son dos bytes). Además de copiar la letra modifico el atributo, en este caso verde sobre fondo azul.
La instrucción "int 21h" llama al MSDOS para que ejecute la función codificada en AH. AH=4Ch dice al sistema operativo que el programa ha finalizado, para que libere la memoria que ocupa y le ceda el control. El 00h en AL hace que devuelva el código de error 0, que otros programas podrían usar. Por convenio significa que la ejecución ha sido correcta. Un programa que no contase con alguna función de este tipo seguiría ejecutándose incluso después de finalizado, pues el ordenador consideraría que lo que hay detrás son instrucciones válidas. Esto en MSDOS conduce generalmente a un cuelgue bastante majo (si estás bajo Windows, con un poco de suerte solamente te cierra la ventanita del DOS, y tan campante), no sin antes habernos escupido en pantalla montones de datos incoherentes.
El sistema operativo proporciona gran cantidad de funciones o servicios que pueden ser utilizadas por los programas. Por ejemplo, en el caso de imprimir una cadena de texto, si se sustituyese '0' por '$' y se llamase al DOS mediante
mov ah,9h ;función 9, imprimir cadena mov dx,offset Texto ;offset de la cadena de texto (segmento por defecto DS) int 21h ;llamada al sistema
tendríamos lo mismo que copiando la cadena "a mano" a la memoria de vídeo (salvo que no podríamos determinar el color del texto, pues esta opción no la provee esta función del DOS). Esta función nos imprime una cadena terminada en '$' empezando en la posición actual del cursor. Tan importante como saber programar es saber el sistema operativo que se está manejando (a fin de no trabajar a lo tonto, más que nada). En el tema de entrada/salida veremos parte de estas funciones, tanto del MSDOS como de la BIOS.
CAPITULO IV. DIRECTIVAS Y MÁS DIRECTIVAS.
Conocer las directivas que acepta nuestro ensamblador simplifica mucho el trabajo, pero a fin de no hacer esto soporífero (aunque creo que ya es demasiado tarde) sólo incluiré las típicas. También hay que tener en cuenta que esta parte puede cambiar bastante de un ensamblador a otro (para los linuxeros, quizá incluya más adelante una sección de NASM). Por otra parte tengo entendido que MASM (el de mocosoft) funciona prácticamente igual -no sé si será verdad-. Quizá sea esta la parte más ingrata del ensamblador, pero bueno, la ventaja es que un ratillo se asimila.
El ensamblador admite operadores de multiplicación, suma, resta, paréntesis.. Cuando toca ensamblar, él solito opera y sustituye. Por ejemplo:
MOV AX,2*(2+4)
es exactamente lo mismo que
MOV AX,12 ;por defecto considera notación decimal, que no cunda el pánico
Dos muy útiles (imprescindibles, diría yo) son SEG y OFFSET, que equivalen al valor del segmento y desplazamiento respectivamente (offset ya lo usamos en el programa anterior)
HIGH y LOW devuelven la parte alta y baja (de 8 bits) de la expresión que preceden:
MOV AX,LOW 2050; AX contiene 2h, porque 2050d=802h
Definición de datos:
Ejemplos:
DB 'Soy una cadena' | ;es cierto, es una cadena |
DW 12,1 | ;0C00h 0100h (ordenación Intel, no se te olvide) |
DT 3.14159265359 | ;representación en coma flotante de 10 bytes PI |
Lo de definir números reales se puede hacer con DD (precisión simple), DQ (precisión doble) y DT (a veces llamado real temporal, una precisión de la leche; 64 bits para la mantisa y 15 para el exponente o_O')
Si hay que repetir algo varias veces se usa DUP, por ejemplo:
DB 25 DUP (?) | ;reserva 25 bytes de valor indefinido |
DB 25 DUP (1) | ;reserva 25 bytes y lo llena de unos |
DB 10 DUP (1,2) | ;1,2,1,2,1,2... |
DB 10 DUP (1, 3 DUP (0)) | ;1,0,0,0,1,0,0,0.. |
ORG es más o menos igualita a la del del motorola, especificando el offset dentro del segmento actual donde se ubicará el código o datos que la sigan. Se usa sobre todo para ensamblar programas de tipo COM, que explicaré por ahí abajo en algún ejemplo... En la mayor parte del texto la ignoraré por completo. Ejemplo:ORG 100h
EQU Equivalencia. El ensamblador sustituye en todo el fuente la palabra indicada por la expresión que sigue a EQU. Una vez establecida una equivalencia, no se puede modificar en todo el programa.
Siglo EQU 21
MOV AX,Siglo ;AX ahora contiene 21d
Siglo = 21
MOV AX,Siglo ;una manera alternativa de hacer lo mismo
Procesadores/Coprocesadores
.8086 .8087
.286 .287
.386 .387
.486 .487
.586 .587
Indican al ensamblador que se usará ese procesador/coprocesador. Si se pone un coprocesador tiene que estar obligatoriamente el procesador correspondiente. Por defecto se usa .8086 sin coprocesador. Se pueden hacer anidamientos; por ejemplo, ejecutar código de 32 bits dentro de un programa de 16.. Cosa algo peregrina en mi opinión, pero bueno, allá cada cual.
INCLUDE archivo
Sustituye la directiva por el contenido del archivo (a la manera del #include de c)
MACRO
Definición de una macro:
Nombre MACRO [Argumentos] ENDM Donde se encuentre una llamada a la macro se copiará el contenido de ésta sustituyendo los argumentos. Por ejemplo: PRINT MACRO Cadena MOV DX,OFFSET Cadena MOV AH,9 INT 21h ENDM PRINT Texto1 PRINT Texto2 Hace lo mismo que MOV DX,OFFSET Texto1 MOV AH,9 INT 21h MOV DX,OFFSET Texto2 MOV AH,9 INT 21h
Aunque es tentador hacerlo, no hay que abusar de ellas porque el código engorda que da gusto. Las macros son más rápidas que las subrutinas (pues ahorran dos saltos, uno de ida y uno de vuelta, más los tejemanejes de la pila), pero a menudo no compensa por muy bonitas que parezcan: el que la pantalla de bienvenida de un programa cargue un par de milisegundos antes o después no se nota. Claro que ahora la memoria es cada vez menos problema...
Para especificar que una etiqueta dentro de la macro es local, hay que poner la directiva "LOCAL etiqueta" justo detrás de la directiva MACRO, a fin de que no nos dé error por etiqueta duplicada cada vez que usemos dicha macro:
EJEMPLOBOBO MACRO
LOCAL EtiquetaInutil
EtiquetaInutil:
ENDM
Si llamamos a esta macro varias veces, cada vez que se la llame usará un nombre distinto para la etiqueta (pero igual en toda la macro, claro).
Esto es básicamente lo que en el motorola hacía escribiendo # delante de la etiqueta.
...y eso es todo en cuanto a directivas. Hay muchas más. Existen deficiones de condicionales para que ensamble unas partes de código u otras en función de variables que definamos con EQU, comparadores, operadores lógicos.. Un bloque muy importante y que no he descrito con el detalle que merece es el de las directivas de definición de segmento. Por el momento usaré la "plantilla" del primer programa; ya introduciré esas directivas cuando comience a usarlas en los ejemplos.
CAPÍTULO V: JUEGO DE INSTRUCCIONES DEL 8086
¡Mamá, ya sé sumar!
A continuación voy a listar el juego de instrucciones del 68000, poniendo tras cada una de ellas la(s) equivalente(s) -si la(s) hubiera- del 8086; entre paréntesis indicaré, si toca, los flags que altera cada una. Por seguir el orden típico en estos casos (y para no pensar demasiado), empezaré por las instrucciones de movimiento de datos. Las instrucciones para operar con BCD y alguna cosilla más por ahí de escasa utilidad no están; para la lista completa, a otro sitio. Tampoco están las instrucciones exclusivas del modo protegido (porque para poder usarlas habría que explicar antes un montón de cosas más). Al final explicaré cómo manejar ciertas cosillas que tenía el 68000. A veces distinguiré entre instrucciones de los 8086 y los 386+, pero más como curiosidad que como algo práctico (¿alguien tiene un 286 y quiere programarlo en asm? ¿nadie? ah, sí, allí en la quinta fila del anfiteatro veo una mano levantada..)
Por destino y origen no es necesariamente válido cualquier modo de direccionamiento.. En las instrucciones básicas (movimiento, operaciones aritmético- lógicas) impera en gran parte el sentido común, pero en otras ya no tanto. Lo que suelo hacer yo es probar, y si da errores al ensamblar, cambiarlo. Tampoco le veía mucha utilidad matarme a hacer una tabla cuando las hay a miles, por ahí, empezando por las que proporciona Intel.
- INSTRUCCIONES DE MOVIMIENTO DE DATOS
MOV destino, origen (MOVe, mover)
Bueno, qué voy a contar de ésta que no haya dicho ya.. Vale, sí, un pequeño detalle. Cuando se carga un registro de segmento el micro inhibe las interrupciones hasta después de ejecutar la siguiente instrucción. Normalmente esto nos dará igual, pero así nadie se llevará sorpresas. MOV vale para todo, salvo para puertos. A los puertos en el 8086 se accede mediante dos instrucciones específicas: IN y OUT. De esto hablaré cuando toque hacerlo sobre E/S. Mucho ojito, que MOV no modifica los flags.
No hay ningún equivalente a MOVEM en el 8086. Realmente la única utilidad importante de esta orden era guardar registros en la pila, y esto se puede hacer con las instrucciones específicas de manejo de pila que veremos posteriormente. No problem.
No existe. No me mires con esa cara, que no existe. Aquí se borra como los hombres, a lo bestia, vamos: hay de hecho muchas alternativas. La más obvia es un "MOV destino, 0". Otras más imaginativas -y estúpidas- incluyen "SUB destino, origen", "AND destino, 0" (imagínate lo que hacen). Para poner a 0 memoria se usa MOV. Cuando se necesita poner a 0 un registro se usa una instrucción más compacta y, por tanto, rápida: "XOR origen, destino", que viene a ser la EOR del motorola. Si me ves muchos "XOR AX,AX" y cosas similares no me habré vuelto loco, es que se hace así.
XCHG destino, origen (eXCHanGe, intercambiar)
Intercambia destino con origen; no se puede usar con registros de segmento. Esta instrucción tiene más utilidad que en el motorola, pues algunas instrucciones tienen asociados registros específicos.
No tiene equivalencia propiamente dicha, pero como se puede acceder a un registro de datos a su parte alta y baja (de la parte de 16 bits) independientemente, es posible hacer "XCHG AH,AL" sin ningún problema. Si a alguien le hace falta hacer lo propio con 32 bits o con registros de índice, hay que echarle un poco de maña (¿rotaciones, quizá?).
- OPERACIONES ARITMETICAS
ADD destino, origen (ADDition, sumar) {O,S,Z,A,P,C}
Suma origen y destino, guardando el resultado en destino. Si "me llevo una" el bit de carry se pone a 1 (y si no, a 0).
ADC destino,origen (ADdition with Carry, sumar con acarreo) {O,S,Z,A,P,F}
Suma origen, destino y el bit de carry, guardando el resultado en destino. A la manera del ADDX del 68K, sirve para sumar números de más de 16 bits arrastrando el bit de carry de una suma a otra. Es posible poner a 1 el carry directamente mediante STC (SeT Carry) o a 0 mediante CLC (CLear Carry), ambas instrucciones sin argumentos.
INC destino (INCrement, incrementar) {O,S,Z,A,P}
Incrementa el operando destino en 1. Puede ser un byte o mayor. Si se incrementa una posición de memoria hay que especificar tamaño con WORD PTR, etc. Esta instrucción no modifica el bit de carry; si quieres detectar cuándo "te pasas" usa el bit Zero o el de signo.
SUB destino, origen (SUBstract, resta) {O,S,Z,A,P,C}
Resta a destino lo que haya en origen.
SBB destino, origen (SuBstract with Borrow, restar con llevada) {O,S,Z,A,P,C}
Resta a destino lo que haya en origen. Si el flag C=1 resta uno más. Análoga al SUBX del motorola.
DEC destino (DECrement, decrementar) {O,S,Z,A,P}
Igual que INC, pero que resta 1 en vez de sumarlo.
IMUL origen (Integer MULtiplication, multiplicación entera con signo) {O,C}
Multiplica origen, con signo, de longitud byte o word, por AL o AX respectivamente. Si origen es un byte el resultado se guarda en AX; si es tamaño word se almacena en el par DX-AX (parte alta en DX, parte baja en AX). Si las mitades de mayor peso son distintas de 0, sea cual sea el signo, CF y OF se ponen a uno. En el caso del 386+, además, se puede usar un operando origen de 32 bits. Si es así se multiplica entonces por EAX, dejando el resultado de 64 bits en el par EDX-EAX. El operando debe ser un registro o un dato de memoria (nada de inmediatos). Esto se aplica para IMUL, MUL, IDIV y DIV.
MUL origen (MULtiplication, multiplicación entera sin signo) {O,C}
Como IMUL, solamente que multiplica enteros sin signo.
DIV origen (DIVide, división entera sin signo)
Divide números sin signo. Calcula el cociente y el resto de dividir AX entre el operando (byte). Si el operando es de 16 bits lo que divide es el par DX-AX. Si el operando es de 32 bits (80386), lo que divide es el par EDX-EAX. El cociente lo guarda en AL, AX o EAX según el caso. El resto en AH, DX o EDX. Si el cociente es mayor que el tamaño máximo (8, 16 o 32 bits) tanto cociente como resto quedan indefinidos, y salta una interrupción 0 (luego veremos cómo van estas cosas, pero te puedes hacer una idea de que no es muy sano). Si divides por cero pasa lo mismo.
IDIV origen (Integer DIVide, división entera con signo)
Igual que DIV, sólo que para números con signo.
- INSTRUCCIONES DE SOPORTE ARITMÉTICO
Estas son una verdadera fiesta, porque pueden extender el signo dentro de un registro o hacia otro (cosa a veces necesaria ya que para divisiones se pueden usar dos registros para almacenar un número). Trabajan fundamentalmente sobre el acumulador, por lo que no son demasiado flexibles. Las MOVSX y MOVZX -del 386- dan mucho más juego.
CWD (Convert Word to Doubleword, convertir palabra a palabra doble)
Extiende el signo de AX a DX, resultando el número DX-AX
CQD (Convert Doubleword to Quad-word, convertir palabra doble a palabra cuádruple)
Extiende el signo de EAX a EDX, resultando el número EDX-EAX
CBW (Convert Byte to Word, convertir byte a palabra)
Extiende el signo de AL a AH, resultando el número AX
CWDE (Convert Word to Doubleword Extended, convertir palabra a palabra doble extendida)
Extiende el signo de AX a EAX, resultando el número EAX
MOVSX destino,origen (Move with Sign Extension, mover con extensión de signo)
Mueve origen a destino, extendiendo el signo. Si el destino es de 16 bits, origen ha de ser de 8. Si destino es de 32 bits, origen puede ser de 8 o de 16. Sólo acepta mover a un registro (de datos).
MOVZX destino,origen (Move with Zero Extension, mover con extensión de ceros)
Exactamente como MOVZX, sólo que en vez de extender el signo rellena de 0s.
NEG destino (NEGate, negar){O,S,Z,A,P,C}
Cambia de signo el número en complemento a dos del destino. NEGX que yo sepa no tiene nada parecido.
-INSTRUCCIONES LÓGICAS
AND destino,origen
OR destino,origen
XOR destino,origen
NOT destino
Estas yo creo que están bien claritas :)
-DESPLAZAMIENTOS Y ROTACIONES
SAL destino,origen (Shift Arithmetic Left, desplazamiento aritmético a la izquierda)
SAR destino,origen (Shift Arithmetic Right, desplazamiento aritmético a la derecha) {O,S,Z,P,C}
SHL destino,origen (SHift logic Left, desplazamiento lógico a la izquierda)
SHR destino,origen (SHift logic Right, desplazamiento lógico a la derecha) {O,S,Z,P,C}
ROL destino,origen (ROtate Left, rotación a la izquierda)
ROR destino,origen (ROtate Right, rotación a la derecha) {O,S,Z,P,C}
RCL destino,origen (Rotate with Carry Left, rotación a la izquierda con carry)
RCR destino,origen (Rotate with Carry Right, rotación a la derecha con carry) {O,S,Z,P,C}
Ambas desplazan o rotan el operando destino hacia el sentido indicado tantas veces como diga el operando origen. Este último operando puede ser un valor inmediato o CL.
-INSTRUCCIONES DE COMPARACIÓN
No existe. Hay que sustituirla por un CMP.
CMP operando1,operando2 (CoMPare, comparar) {O,S,Z,A,P,C}
Funciona exactamente igual que SUB solamente que sin almacenar el resultado (o sea, efectua la operación operando1-operando2, alterando los flags en consecuencia). Al igual que la instrucción CMP del motorola se utiliza antes de efectuar un salto condicional.
TEST operando1,operando2 (TEST, comprobar) {O,S,Z,A,P,C}
Como la anterior, pero con la operación AND. Muy importante no confundirla con TST del 68K, que no tiene nada que ver.
-INSTRUCCIONES DE SALTO
JMP dirección
Salta a la dirección indicada. Este salto puede ser tanto lejano como cercano, y la dirección puede venir dada en valor inmediato (generalmente mediante una etiqueta) o en memoria. En este último caso si es un operando de 16 bits se considerará el offset de un salto cercano, y si es de 32 bits la primera palabra indicará el offset y la segunda el segmento. El tipo de salto se puede indicar con la dirección mediante los prefijos near o far en cada caso, pero si omiten el ensamblador sustituirá el mnemónico por el código correspondiente. Existe un tercer tipo de salto denominado corto indicado por el prefijo short, cuya dirección del salto viene codificada en un byte que indica la distancia (complemento a dos) del punto de salto. Dado que todo esto lo gestiona realmente el ensamblador, no tiene demasiado interés.
Jcc dirección
"cc" representa la condición. Las distintas condiciones se presentan en la tabla inferior. Para no poner todas las equivalencias con el motorola se escriben directamente los efectos de las instrucciones. El ensamblador de Intel tiene algunos mnemónicos distintos para las mismas instrucciones, lo cual viene muy bien para recordar qué comparación usar en cada caso. Por ejemplo, JZ (saltar si cero) y JE (saltar si iguales) son exactamente la misma instrucción. Una curiosidad: en el 8086 los saltos condicionales sólo podían ser cortos (¡un byte!), lo que obligaba a utilizar la comparación contraria y un JMP para poder saltar a más de 127/128 bytes de distancia. En el 80386 estos saltos son de hasta 32 bits.
Instrucción | Condición | ||
---|---|---|---|
JZ | Jump if Zero | salta si cero | ZF=1 |
JNZ | Jump if Not Zero | salta si no cero | ZF=0 |
JC | Jump if Carry | salta si acarreo | CF=1 |
JNC | Jump if Not Carry | salta si no acarreo | CF=0 |
JO | Jump if Overflow | salta si overflow | OF=1 |
JNO | Jump if Not Overflow | salta si no overflow | OF=0 |
JS | Jump if Sign | salta si signo | SF=1 |
JNO | Jump if Not Sign | salta si no signo | SF=0 |
JP/JPE | Jump if Parity (Parity Even) | salta si paridad (Paridad Par) | PF=1 |
JNP/JPO | Jump if Not Parity (Parity Odd) | salta si no paridad (Paridad Par) | PF=0 |
Cuando queremos hacer un salto condicionado por una comparación, y no directamente por el estado de los flags, lo que hacemos es una comparación CMP A,B. A continuación usamos una instrucción de salto de entre las siguientes:
Instrucción | Condición | ||
---|---|---|---|
JA | Jump if Above | salta si por encima | A>B (sin signo) |
JAE | Jump if Above or Equal | salta si por encima o igual | A>=B (sin signo) |
JB | Jump if Below | salta si por debajo | A<B (sin signo) |
JBE | Jump if Below or Equal | salta si por debajo o igual | A<=B (sin signo) |
JE | Jump if Equal | salta si igual | A=B |
JG | Jump if Greater | salta si mayor | A>B (con signo) |
JGE | Jump if Greater or Equal | salta si mayor o igual | A>=B (con signo) |
JL | Jump if Less | salta si menor | A<B (con signo) |
JLE | Jump if Less or Equal | salta si menor o igual | A<=B (con signo) |
Para facilitar aún más el trabajo, existen JNA,JNAE,JNB,JNBE,JNE,JNG,JNGE,JNL, JNLE. No hacía falta que estuvieran, porque son equivalentes a JBE,JB,JAE,JA,JNZ, JLE,JL y JG, pero así nos evitan pensar un poco. Con recordar que Below/Above son para números sin signo y Less/Greater para con signo, los mnemónicos se deducen inmediatamente.
Una instrucción muy útil en combinación con las siguientes (LOOP y variaciones) es JCXZ (Jump if CX is Zero, salta si CX es cero).
Para hacer ciclos con un contador usábamos DBRA; las otras instrucciones eran un poco más retorcidas, y aunque tienen utilidad (oscura utilidad) están más relacionadas con las instrucciones de cadena del 8086 (que veremos posteriormente). Para hacer ciclos la familia del 8086 cuenta con LOOP y la pareja LOOPE/LOOPZ (mnemónicos de lo mismo), de funcionamiento bastante más lógico que DBRA (pues la condición de fin de ciclo es contador=0 en vez de contador=-1)
LOOP dirección (LOOP, ciclo)
Decrementa CX y si el resultado es distinto de 0 salta a la dirección dada. Estrictamente hablando no es una dirección sino un desplazamiento en el rango +127/-128 bytes para el 8086, o +(2^31-1)/(-2^31) para el 80386. Como al programar pondremos en lugar de ese desplazamiento una etiqueta, nos da lo mismo.
LOOPZ/LOOPE tienen la misma sintaxis que LOOP. Lo que hacen es decrementar CX y saltar a la dirección dada si CX es distinto de 0 y ZF=1. La idea es efectuar un ciclo con una condición dada, que se repita un máximo de CX veces. Cuando el operando es de 32 bits LOOP, LOOPE y LOOPZ operan sobre ECX (en nuestro caso, siempre CX). LOOPNZ/LOOPNE son iguales a los anteriores, sólo que la condición adicional al salto es ZF=0 en vez de ZF=1.
Como cuando CX=0 estos ciclos se ejecutan en principio 2^16 veces, podemos evitarlo mediante el uso de JCXZ justo antes del ciclo.
-MANEJO DE LA PILA
Contamos con instrucciones específicas para el manejo de la pila. Las dos básicas son PUSH origen (empujar) y POP destino (sacar). La primera decrementa el puntero de pila y copia a la dirección apuntada por él (SS:SP) el operando origen (de tamaño múltiplo de 16 bits), mientras que la segunda almacena el contenido de la pila (elemento apuntado por SS:SP) en destino y altera el puntero en consecuencia. Si el operando es de 16 bits se modifica en 2 unidades, de 32 en 4, etc.
PUSHA y POPA (de PUSH All y POP All) almacenan en la pila o extraen de la pila respectivamente los registros básicos en este orden: AX,CX,DX,BX,SP,BP,SI,DI. En el caso de los registros extendidos lo que tenemos son PUSHAD y POPAD, empujando a la pila entonces EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI. El puntero de pila que se introduce en la misma es el que hay antes de empujar AX/EAX. Cuando se ejecuta POPA el contenido de SP/ESP de la pila no se escribe, sino que "se tira". Se emplean generalmente al principio y final de una subrutina, para preservar el estado del micro.
PUSHF y POPF (PUSH Flags y POP Flags) empujan a la pila o recuperan de ella el registro de flags (16 bits).
-MANEJO DE SUBRUTINAS
CALL dirección (CALL, llamar)
Funciona como JSR y BSR. Empuja a la pila la dirección de retorno (la de la siguiente instrucción) y salta a la dirección dada. Si el salto es cercano empuja a la pila solamente el offset, mientras que si es lejano empuja offset y segmento. En vez de la dirección admite también un operando en memoria (word ptr o dword ptr, según sea una llamada cercana o lejana), saltando a la dirección dada por el puntero correspondiente, o incluso un registro de 16 bits (salto cercano únicamente). Hay que recordar que en caso de ser un puntero lejano en la primera palabra se almacena el offset y en la segunda el segmento.
RET,IRET (RETurn, regresar)
Extrae una dirección de la pila y salta a ella. Puede ser un retorno cercano mediante RETN (Near) o lejano mediante RETF (Far); en el primer caso extrae el offset y en el segundo segmento y offset. Con el uso genérico de la instrucción RET el ensamblador elige la apropiada; sin embargo es buena costumbre indicarlo específicamente. Es muy importante que la subrutina deje la pila como estaba justo al ser llamada para que la dirección de retorno quede en la parte superior, pues de lo contrario al llegar a RET saltaríamos a cualquier sitio menos la dirección correcta. IRET es el retorno de una interrupción; además de volver, restaura el registro de flags. Al acudir a una interrupción este registro se guarda en la pila.
En el 8086 no había equivalentes a éstas, y había que hacer esas operaciones "a mano". Estas instrucciones facilitaban el uso de variables locales dentro de subrutinas, así como el paso de argumentos por pila. LINK empujaba a la pila el registro dado y almacenaba en él el puntero de pila, desplazándolo varias posiciones. UNLK deshacía el efecto restaurando el registro que habíamos usado como puntero de pila "fijo" y eliminando el hueco creado. Sucede aquí que tenemos un registro que usa por defecto el segmento de pila: BP. Éste es el registro que emplearemos como puntero inamovible para señalar a argumentos y variables locales. Primero lo haremos todo paso a paso porque es importante entender el procedimiento:
Vamos a crear una subrutina que sume dos números de 16 bits (un poco estúpido, pero es para hacerlo lo más simple posible). El programa principal haría algo así:
... push word ptr [Sumando1] push word ptr [Sumando2] sub sp,2 ;hueco para el resultado call SumaEstupida pop word ptr [Resultado] ;saca el resultado add sp,4 ;elimina los sumandos de la pila ... y la subrutina sería: SumaEstupida PROC ;SP apunta a la dirección de retorno, SP+2 al resultado, SP+4 a Sumando2, SP+6 a Sumando1 push bp ;ahora SP+4 apunta al resultado, SP+6 a Sumando2 y SP+8 a Sumando1 mov bp,sp ;BP+4 apunta al resultado, BP+6 a Sumando2 y BP+8 a Sumando1 push ax ;altero SP pero me da igual, porque tengo BP como puntero a los argumentos mov ax,[bp+6] ;cargo el Sumando2 add ax,[bp+8] ;le añado el Sumando1 mov [bp+4],ax ;almaceno el resultado pop ax ;restauro AX pop bp ;restauro BP retn SumaEstupida ENDP
Mediante la directiva EQU podemos hacer por ejemplo SUM1=BP+8 tal que para referirnos al sumando 1 hagamos [SUM1], y así con el resto. RET admite un operando inmediato que indica el número de bytes que queremos desplazar el puntero de pila al regresar. De esta manera, si colocamos primero el hueco para el resultado y luego los sumandos, mediante un "RETN 4" podemos ahorrarnos "ADD SP,4" tras la llamada. Algunos lenguajes de alto nivel hacen las llamadas a funciones de esta manera; no así el C. Usar un ADD en lugar de un "RET x" tiene la ventaja de que podemos usar un número variable de argumentos por pila; en general al que mezcle ensamblador con C le parecerá la mejor alternativa.
Los procesadores 286+ proporcionan un método alternativo de realizar estas operaciones tan comunes en los lenguajes de alto nivel (cada vez que llamas a una función el micro organiza una fiesta de estas), con un cierto nivel de sofisticación. La primera instrucción al respecto es ENTER, que recibe dos operandos. El primero indica la cantidad de memoria que se reservará en la pila, en bytes (asignación de espacio a variables locales). El segundo es el llamado nivel de anidamiento del procedimiento, y hace referencia a la jerarquía que presentan las variables en el programa; determinará qué variables serán visibles para la función que ha sido llamada. Esto encierra bastantes sutilezas que corresponden a cómo se construye el código máquina de este tipo de estructuras y a este nivel no interesa meterse en berenjenales; si hubiera que usar ENTER, dejaríamos el segundo operando a 0. Digamos que si quisiéramos hacer una especie de "LINK" para reservar 4 bytes para variables locales, usaríamos la instrucción "ENTER 4,0"; empuja BP, copia SP en BP, decrementa SP en 4.
La instrucción que deshace el entuerto es LEAVE, sin argumentos. Lo único que hace es copiar BP en SP para liberar el espacio reservado, y a continuación restaurar BP desde la pila.
Para ver en más detalle el manejo de la pila en las subrutinas, así como las posibilidades de ENTER/LEAVE y el concepto de nivel de anidamiento de un procedimiento recomiendo acudir a la documentación Intel, al texto "Intel Architecture Software Developer's Manual Volume 1: Basic Architecture", que pese a lo que aparenta es un texto muy legible. Este tema lo aborda el capítulo 4, más concretamente en "Procedure calls for block-structured languages". Si alguien está pensando en mezclar C con ensamblador encontrará este texto de gran interés.
El programador de la subrutina ha de tener muy en cuenta dos cosas. La primera, si la subrutina va a ser llamada desde el mismo segmento (generalmente funciones de un módulo del programa principal) u otro, pues dependiendo de ello CALL introduce distinto número de elementos en la pila. La segunda cosa, en la que quizá alguno no haya reparado, es si estamos programando en 16 o en 32 bits. Un programa en modo real introduce en la dirección de retorno CS:IP (o IP en un salto cercano) mientras que en un sistema 32 bits, modo protegido, las llamadas introducen CS:EIP (o EIP). Si usásemos ENTER/LEAVE también estaríamos manipulando ESP y EBP en vez de SP y BP. Nuevamente considerando esto erróneamente intentaremos acceder a los argumentos en posiciones equivocadas de la pila. En nuestros ejemplos serán offsets de 16 bits, pero cuando se quiera trasladar lo aprendido a Windows o Linux será muy importante no olvidarlo.
-INSTRUCCIONES PARA OBTENER DIRECCIONES
LEA destino,origen (Load Effective Address, cargar dirección efectiva)
Carga la dirección efectiva del operando origen en destino. "LEA AX,[BX+DI+2]" calcularía la suma BX+DI+2 e introduciría el resultado en AX (y no el contenido de la dirección apuntada por BX+DI+2, pues eso sería un MOV). Como destino no se puede usar un registro de segmento.
LDS destino,origen (Load pointer using DS, cargar puntero usando DS)
Esta instrucción y sus variantes ahorran mucho tiempo e instrucciones en la carga de punteros. origen será siempre memoria conteniendo un puntero, es decir, un segmento y un desplazamiento. La primera palabra corresponde al offset y la segunda al segmento. El offset se carga en destino y el segmento en DS. Si estamos trabajando en un modo de 32 bits el desplazamiento será de 32 bits y el segmento de 16. Existen más instrucciones, una por cada registro de segmento: LES,LFS,LGS y LSS (mucho cuidadito con esta última).
-INSTRUCCIONES DE MANEJO DE BITS
BT/BTS/BTR/BTC registro,operando (Bit Test/Test&Set/Test&Reset/Test&Complement
Estas instrucciones lo primero que hacen es copiar el bit del registro indicado por el operando, sobre el bit de carry. El operando puede ser un registro o un valor inmediato de 8 bits. Una vez hecho esto no hace nada (BT), lo pone a 1 (BTS), lo pone a 0 (BTR) o lo complementa (BTC) según corresponda.
-MISCELÁNEA
HLT (HaLT, parada)
Detiene el microprocesador hasta la llegada de una interrupción o de una señal de reset.
NOP (No OPeration, no hacer nada)
No hace nada más que consumir los ciclos de reloj necesarios para cargar la instrucción y procesarla. Corresponde a la codificación de XCHG AX,AX
INT inmediato
Salta al código de la interrupción indicada por el operando inmediato (0-255). INTO, sin operandos, genera una interrupción 4 si el bit de overflow está a 1. En caso contrario continúa con la instrucción siguiente.
-INSTRUCCIONES DE CADENA. Algo bastante distinto al 68000
Más de uno habrá echado en falta los modos de direccionamiento con post y pre decremento del motorola. Aquí no hay nada equivalente, pero es posible olvidarse de ellos en determinadas circunstancias pues existen instrucciones específicas para algunas operaciones que típicamente usan en el 68000 ese modo de direccionamiento.
Existe un conjunto de instrucciones conocido a veces como "de cadena", que sirven para mover y comparar elementos dispuestos en array, incrementándose cada vez el puntero a la cadena. Éstas se ven afectadas por el bit de dirección (que indica el sentido en que se recorre la cadena). Mediante la instrucción STD (SeT Direction flag) hacemos DF=1, y con CLD (CLear Direction flag) DF=0
LODSB y LODSW (LOaD String, Byte y LOad String, Word), sin operandos, leen el byte o palabra en la dirección dada por DS:SI y la almacenan en AL o AX respectivamente. Podemos usar en su lugar LODS operando, con un operando en memoria, especificando con ello si se trata de un LODSB o LODSW según el tipo. Si el operando lleva segment override será de este segmento de donde se tomen los datos con SI. Por claridad es preferible usar LODSB o LODSW.
¿Qué tiene todo esto que ver con lo mencionado sobre el flag de dirección? Si el DF=0 SI se incrementa en uno para LODSB y dos para LODSW (apunta al siguiente byte/palabra). Si DF=1 se lee el array en dirección inversa, es decir, SI se decrementa. Así, por ejemplo, podemos ir cargando en el acumulador el contenido de un array a lo largo de un ciclo para operar con él.
STOSB y STOSW (STOre String, Byte/Word) funcionan con el mismo principio en cuanto al flag de dirección, y lo que hacen es almacenar en ES:DI el contenido del acumulador (AL o AX según cada caso). De manera análoga podemos usar STOS operando.
MOVSB y MOVSW (MOV String, Byte/Word) van más allá; mueven el byte o palabra en DS:SI a ES:DI. Vemos ahora que SI es el Source Index o índice fuente, y DI el Destination Index o índice destino. Tras el movimiento de datos SI y DI son incrementados o decrementados siguiendo la lógica descrita para LODS. Es admisible además la instrucción MOVS destino,origen (ambos operandos en memoria). El ensamblador emplea los operandos para determinar si se trata de un MOVSB o MOVSW al codificar la instrucción correspondiente; además el operando origen puede llevar un prefijo de segmento (no así el destino) en cuyo caso se emplea ése registro de segmento como origen.
¡Ajajá, así que ahora podemos mover arrays de bytes o palabras metiendo estas instrucciones en un ciclo, olvidándonos de andar manipulando los registros índice! Pues no. Es decir, sí, claro que se puede, pero no se hace así. Existe un prefijo llamado REP que se coloca antes de la instrucción de cadena en cuestión, que lo que hace es repetir el proceso tantas veces como indique CX. Supongamos que queremos llenar de ceros 100 bytes a partir de la posición A000h:0000h. Podemos currarnos un ciclo que maneje un índice, empezar a hacer mov's de 0s a [di].. Una solución más inteligente y rápida sería:
mov cx,50d
mov ax,0A000h
mov es,ax
xor di,di
xor ax,ax
rep stosw
Como cada palabra son 2 bytes, lo que hacemos es cargar CX con 50 (pondremos a 0 50 palabras), apuntar con ES:DI al primer elemento, poner AX a 0, y empezar a almacenar AX en cada posición, hasta 50 veces. Al finalizar ese fragmento de código DI contendrá el valor 100, pues se ha incrementado en 2 en cada repetición del ciclo.
Pero como se suele decir, aún hay más. SCASB y SCASW (SCAn String) realizan la comparación "CMP AL,ES:[DI]" o "CMP AX,ES:[DI]", alterando lógicamente los flags, y a continuación modificando DI como es debido. (Existe SCAS operando con idénticas consideraciones a las anteriores instrucciones de cadena con operando en memoria).CMPSB y CMPSW (CoMPare String) equivalen a "CMP DS:[SI],ES:[DI]" tamaño byte o word según corresponda, alterando los flags en función de la comparación e incrementando SI y DI según el tamaño del dato y el flag de dirección (habiendo asimismo un CMPS que funciona análogamente a MOVS en cuanto a llevar operandos en memoria). Esto puede no parecer impresionante (ya que REP aquí no pinta nada), pero es que existen dos prefijos (en realidad 4, pero son parejas de mnemónicos de instrucciones idénticas) más, REPE/REPZ y REPNE/REPNZ. Estos prefijos funcionan como REP en cuanto a que repiten la instrucción que preceden tantas veces como indique CX excepto en que además ha de verificarse la condición que representan. REPZ se repite mientras el flag de cero esté a uno (REPeat while Zero), mientras que REPNZ se repite, lógicamente, mientras esté a cero (REPeat while Not Zero). De esta manera es muy fácil realizar un código que localice una determinada palabra dentro de un array, o que encuentre la diferencia entre dos zonas de memoria. Si antes de llegar CX a 0 se produce la condición contraria a la del prefijo, se sale del ciclo; DI/SI apuntarán al elemento siguiente al que provocó este efecto. Si se termina con CX=0 habrá que mirar el flag correspondiente para comprobar si en la última comprobación la condición se cumplió o no... pues en ambos casos el ciclo habrá terminado.
Los 386+ incluyen además STOSD,LODSD,MOVSD,SCASD,CMPSD. Éstas trabajan con los registros extendidos; apuntan con ESI/EDI, usan EAX, cuentan con ECX... Por lo demás idénticas (permiten prefijos REP y todo eso).
CAPÍTULO VI: JUEGO DE INSTRUCCIONES DEL 8087 (y del 387) Hemos visto por ahí arriba, en alguna parte, que se pueden almacenar fácilmente
números reales en memoria en distintos formatos mediante directivas. Ahora lo que
hace falta es saber usarlos. Algo tan básico como sumar en coma flotante requiere un
programa un bastante generoso; esto no se hace a mano. Incluso aunque usásemos librerías
ya programadas por algún pobre incauto, nuestro programa sería horrorosamente lento.
Una multiplicación en precisión simple era realizada por el 8086 en unos 10500 ciclos de
reloj. La misma jugada, a manos del 8087, en 135. Si ahora hablamos de raíces cuadradas
tenemos 98000 ciclos de reloj frente a 180. Y puedo asegurarte que el coprocesador del
Pentium tarda muchos menos ciclos de reloj que un 8087. Vamos, que la cosa compensa. Por eso tenemos a nuestro amigo el coprocesador matemático, que se encarga de hacer
sumas, restas, logaritmos, operaciones trigonométricas... Vamos, que menos la colada,
de todo. A fin de sacarle al máximo partido, consideraremos las instrucciones del 80387
(que incluyen algunos más juguetitos que el 8087, como funciones trigonométricas varias)
Para los tiquismiquis con eso de manejar algo de hace más de 15 años diré que la FPU del
Pentium es (ejem, no se tome esto literalmente) un 387, sólo que muchísimo más rápido
(en ciclos de reloj por operación; que los Pentium son más rápidos que los 386 es obvio)
Vamos a ver cómo funciona. Antes de nada hay que avisar que trabaja simultáneamente con el micro, pero no se ven
internamente entre sí; es decir, cada uno se comunica con la memoria, pero no se pasan
datos entre ellos. Aclarado esto vamos con la pila. El coprocesador cuenta con una pila
formada por 8 registros de 80 bits, llamados ST(0), ST(1)... ST(7). ST viene de STack,
claro. ST(0) es el registro arriba de todo, y es equivalente a decir ST. Realmente existe
un puntero de pila, de manera que aunque cuando empujemos un valor a la pila ST(2) pasará
a valer ST(1), ST(1)=ST(0), y ST(0) el valor empujado, realmente no existe movimiento
entre los registros. ST(0) es siempre el elemento que está sobre la pila, y ST(n) el
elemento n posiciones por debajo de aquél. Pensemos, más que en 8 registros, en una pila
de profundidad 8. Y nunca debemos empujar más de 8 valores a la pila, ya que daría
resultados erróneos (NaN) y perderíamos además el valor de los niveles más profundos. El coprocesador maneja 7 tipos de datos nada menos. 3 enteros, 3 reales y uno especial
llamado BCD empaquetado. Los enteros son de 16, 32 y 64 bits (todos ellos siempre
complemento a dos); los reales de 32, 64 y 80 bits; el BCD empaquetado contiene una
representación decimal de 18 dígitos. Como cada byte puede contener dos dígitos BCD, y
un byte se reserva para el signo, 18/2 + 1 = 10 bytes. ¿Cómo se opera con él? El que haya probado alguna vez la notación inversa polaca, como
la de la calculadora HP49 o familia, no tendrá ningún problema. El que no, pues nada, a
prestar atención: Cuando se hace una operación, no se especifican por lo general los
operandos. Estos van implícitos, y son tomados directamente de la pila. Por ejemplo,
cuando se quiere sumar dos números, se dice "¡Suma!". El coprocesador cogerá entonces
los dos primeros números que haya en la pila, los sumará, y colocará en su lugar el
resultado. Existen instrucciones que permiten cierto control sobre los operandos, pero
es necesario coger la mecánica. Vamos con un ejemplo (la pila la represento de izquierda
a derecha; una pila de 3 registros para no llenar espacio a lo tonto) Queremos hacer calcular la hipotenusa de un triángulo rectángulo cuyos catetos valen
3 y 4. La operación será raiz(3^2 + 4^2). Veamos todo paso a paso y el efecto que
ejercemos sobre la pila en cada momento: Sencillito, ¿no? Una cosa más. Cuando se carga un número en la pila del coprocesador, tenga el
formato que tenga, se pasa a real de 80 bits, pues es el tamaño de los registros.
Cuando se saca de la pila y se vuelve a meter en memoria se redondea o se ajusta
a la precisión requerida. Es por esto que a menudo al formato real de 10 bytes se
le llama real temporal.. lo que no quita para que lo podamos usar como
tamaño "estándar" para todas nuestras operaciones de coma flotante. Empecemos a darle caña al copro. Aunque parecen muchas instrucciones, una vez
vistas un poco son muy sencillas de recordar y usar. Esta instrucción en sí no vale para nada. No hace nada más que esperar (un poco
como NOP), y nosotros por lo general nos olvidaremos de ella. ¿Sirve realmente
para algo? ¿Por qué estoy perdiendo el tiempo leyendo esto? Pues sí, sirve para
algo (sobre la segunda pregunta mejor no especular..). Es una instrucción
de sincronización. Comprueba el estado de la FPU (de Floating Point Unit,
para el que le guste la lengua de Shakespeare, o copro para los amigos) por
si hay alguna excepción pendiente. Si es así, espera a que sean atendidas.
¿Tengo que preocuparme yo de eso? No. El ensamblador mete "waits" por ahí, donde
hace falta, a fin de que el micro y la FPU estén perfectamente sincronizados
(no olvidemos que son dos máquinas indendientes, aunque vayan empacadas en el
mismo encapsulado). Incluyo esto para que no pillemos mosqueo cuando estemos
trazando un programa y veamos FWAITs sin, aparentemente, venir a cuento. Al que
quiera saber más sobre esto le recomiendo que le eche un ojo al citado anteriormente
"Intel Architecture Software Developer's Manual, Volume 1. Basic Architecture";
en general el capítulo dedicado a la FPU es especialmente interesante. Instrucciones de manejo de pila: Empuja el contenido de memoria a la pila. Tiene que ser real en coma flotante
de cualquiera de los tamaños válidos. Es válido también (y muy útil) FLD ST(n) Empuja el contenido de memoria a la pila. Tiene que ser un número entero
de cualquiera de los tamaños válidos. Empuja el contenido de memoria a la pila. Tiene que ser un número BCD empaquetado
de 10 bytes Copia ST en memoria sin mover el puntero de pila. El destino ha de ser de 4 u 8
bytes, almacenándolo como un real. Admite FST ST(n); entonces copia ST en ST(n) Copia ST en memoria sin mover el puntero de pila. El destino ha de ser de 2 o 4
bytes, almacenándolo como un entero. Extrae ST y lo guarda en memoria. El destino ha de ser de 4,8 o 10 bytes,
almacenándolo como un real. Extrae ST y lo guarda en memoria. El destino ha de ser de 2,4 u 8 bytes,
almacenando el resultado como un entero. Extrae ST y lo guarda en memoria. El destino ha de ser de 10 bytes, convirtiendo
el resultado en BCD empaquetado. Intercambia ST y ST(n). Si se omite el operando se sobreentiende ST(1) Marca la posición n de la pila como vacía. Cuando se introduce un número en la
pila se desplaza el puntero de pila de manera cíclica; esto quiere decir que si
hemos metido ocho elementos al meter el noveno éste machacará el primero de
ellos. Cuando digo machacar no me refiero a que se sobreescribe; se producirá
un error que colocará un NaN en esa posición. Si por ejemplo tenemos la posición
inferior ocupada, y queremos liberarla a fin de poder echar un elemento más a la
pila, deberemos usar FFREE Incrementa el puntero de pila sin modificar el contenido de los registros. NO
es equivalente a eliminar el primer elemento de la pila, pues éste habrá pasado
a ser el último y no estará vacío (es necesario FFREE para ello) Decrementa el puntero de pila sin modificar el contenido de los registros. Instrucciones de carga de constantes: Empujan a la pila la constante dada por la instrucción. No hay mucho más que
explicar ;) Instrucciones aritméticas (ahora empieza lo bueno) Suma ST+ST(1), extrae ambos operandos de la pila y almacena en ella el resultado. ST = ST + [memoria] ST(n) = ST(n)+ST, retirando el valor de ST de la pila. Resta ST(1)-ST, extrae ambos operandos de la pila y almacena en ella el resultado. ST = ST - [memoria] ST(n) = ST(n)-ST, retirando el valor de ST de la pila. Resta ST-ST(1), extrae ambos operandos de la pila y almacena en ella el resultado. Iguales que las que son sin la "R", solo que haciendo la resta al revés.
(es que me repito más que la sopa de ajo) Multiplica ST(1) por ST, extrae ambos operandos de la pila y almacena en ella el
resultado. ST = ST*[memoria] Memoria ha de ser un entero. ST(n)=ST(n)*ST, retirando ST de la pila. Divide ST(1) entre ST, extrae ambos operandos de la pila y almacena en ella el
resultado. Cuando es I como los Integer de arriba, P Pop, R Reverse, etc etc Pone el signo de ST a positivo (calcula su valor absoluto) Cambia el signo de ST Redondea ST a un entero Reemplaza ST con su raíz cuadrada Suma ST(1) al exponente del número que haya en ST. Esto multiplica ST por dos
elevado a la potencia contenida en ST(1). ST(1) ha de ser entero. Halla el módulo de la división de los dos registros superiores de la pila (se
divide ST entre ST(1)). El resto sustituye a ST, dejando ST(1) como estaba. Pone en ST el valor de la mantisa de ST y en ST(1) el del exponente (desplaza
una posición todo lo que hubiera bajo ST) Instrucciones de trigonométricas Todos los ángulos están siempre en radianes Sustituye ST por su coseno Sustituye ST por su seno Sustituye ST por su seno y su coseno en la misma instrucción (es más rápido que
efectuar dos instrucciones seno y coseno separadas) Sustituye ST por su tangente Calcula ATAN(Y/X), donde Y=ST(1) y X=ST(0). Ambos operandos se destruyen. Instrucciones logarítmicas/exponenciales Calcula ST(1)*log2(ST) y lo almacena en la pila, destruyendo ST y ST(1) Calcula ST(1)*log2(ST+1) ST = 2^ST -1. El ST previo debe estar comprendido entre -1 y 1 ST = ST * 2^ST(1), considerando ST(1) en realidad como la parte entera de
ST(1) Con estas instrucciones podemos calcular el logaritmo de un número en cualquier base,
o potencias de la forma X^Y con un poco de maña, aplicando propiedades logarítmicas. Existen en la FPU, aparte de los registros mencionados y otros que no se comentarán,
dos más de gran utilidad: el registro de control y el registro de status. Este último
contiene bits que se alteran según las operaciones realizadas (como los flags del
procesador), lo que permite realizar comparaciones de números reales. Sin embargo como
no existe una comunicación procesador-coprocesador muy flexible es necesario copiar
algunos bits del registro de status sobre el de flags (a través de AX) y a continuación
realizar el salto en función de esos bits. La descripción de todo esto (más las
instrucciones de comparación y manipulación del estado del coprocesador) es un poco
tedioso, lo omitiré por el momento (espero incluirlo un día de estos). Sobre todas las instrucciones que faltan; creo que una vez cogida la mecánica de
programación en ensamblador, lo mejor es coger directamente la referencia de
instrucciones, tanto de coma flotante como del procesador (el Instruction Set
Reference Manual de Intel) e ir leyendo a ratos algunas páginas (y no, no estoy
chalado, lo digo completamente en serio xD).
CAPÍTULO VII: INTERRUPCIONES (a vista de pájaro) Cuando ocurre una interrupción hardware (porque un dispositivo externo requiere
la atención del microprocesador) el curso del programa puede desviarse a atender dicha
interrupción. Entre instrucción e instrucción el micro comprueba si hay alguna
pendiente; si es así empuja el registro de flags en la pila, y a continuación CS
e IP. Carga entonces CS:IP con la dirección de la rutina de atención a la interrupción
(a menudo ISR de Interruption Service Routine). Cuando la rutina termina vuelve
al punto desde donde saltó mediante IRET, que recupera CS:IP y el registro de flags de
la pila. Cada interrupción tiene asignada un número del 0 al 255 que la identifica. En el
8086 la dirección de cada ISR se encuentra en la tabla de vectores de interrupción
(vemos que todo lo contado es prácticamente idéntico a nuestro querido 68000) ubicada
en el primer kbyte de la memoria RAM; esta tabla contiene 256 punteros de 4 bytes
(offset + segmento), colocados en orden según el código de interrupción asociado a cada
uno. Cuando salta la interrupción X, se lee el puntero en la posición 4*X. En modo
protegido la cosa no va así ni mucho menos; hay en su lugar una cosa algo llamado IDT
(Interrupt Descriptor Table), y a mí todo lo que suene a descriptores me da
dolor de cabeza. Como tampoco vamos a tener, en programas de usuario, acceso a estas
interrupciones en esa circunstancia (modo protegido), que no nos quite el sueño. El flag de interrupciones determina cuándo y cuándo no se aceptan interrupciones.
El bit a 1 las permite, a 0 las inhibe. Manipular este bit es fundamental en operaciones
especialmente delicadas, como recolocar el puntero de pila. Supongamos que queremos
inicializar una pila de 256 bytes en el segmento 4000h, para lo cual tenemos que
hacer SS=4000h, SP=100h. Imaginemos que cuando hacemos SS=4000h salta una interrupción
originada por un evento externo ajeno a nuestro programa (un timer, por ejemplo).
Antes de saltar a la subrutina de atención a la interrupción almacenamos la dirección
de retorno en la pila... que tiene un índice de pila apuntando a no se sabe bien dónde.
Tanto el par SS:SP antiguo como el nuevo apuntaban a direcciones válidas, pero aquí
podemos tener un problema. Hay que inhibir las interrupciones. Para ello tenemos las instrucciones
STI (SeT Interruption flag) y CLI (CLear Interruption flag), que ponen
a uno y a cero respectivamente el flag de interrupciones. (Sobre el ejemplo propuesto
diré que en circunstancias normales no podemos reubicar la pila, pero bueno, la idea
es esa) Existe otro tipo de interrupciones ocasionadas por el programa, pero que funcionan
de manera idéntica; las interrupciones software. Para llamar a una interrupción
determinada usamos la instrucción INT (equivalente en gran medida a TRAP). En
la tabla de vectores de interrupción se encuentran las direcciones de gran cantidad de
funciones proporcionadas por el sistema operativo y la BIOS, a las cuales podemos
llamar mediante esta instrucción. Cuando el micro lee una instrucción INT se comporta
igual en tanto que empuja el registro de flags y la dirección de retorno en la pila,
y vuelve luego mediante un IRET. Es posible además mediante INT forzar una interrupción
que en principio estaba asociada a un evento hardware, pero no es demasiado recomendable. Se puede en MSDOS escribir en la tabla de vectores de interrupción y colocar ahí
la dirección de un programa residente, que es lo que se hace con un driver. En MSDOS el
ratón, por ejemplo, siempre se ha asociado a la INT 33h; no importa quién haya
programado el driver del ratón, ni dónde esté ubicado en memoria, mientras
asociemos las mismas funciones a esa interrupción. Si uno quiere leer el estado del
ratón, pasa los argumentos necesarios (generalmente en los registros) a la ISR, llama a
la INT 33h y lo realiza. Cada fabricante de ratones habrá programado su driver
para que interaccione con su ratón tal que responda igual que el resto de ratones
a esa función. Esto de echarle el guante a los vectores de interrupción con tanta facilidad con el
MSDOS hacía que fuera muy fácil programar un virus (y uno increíblemente pequeño además).
Supongamos que tenemos un ejecutable infectado, que lo que hace es nada más arrancar
cargar el virus, y luego ejecutar el código del programa normalmente. El hipotético
virus comprueba cuando se ejecuta si ya ha infectado el sistema, y si no es así carga
su código en memoria de manera residente (permanece en una parte de la memoria incluso
tras haber terminado la ejecución del programa infectado). Sobreescribe a continuación
el vector de interrupción que más le interese, que será la que active determinadas
funciones del virus. Podemos hacer, por ejemplo, que cada vez que se ejecute un
programa éste quede infectado (cosa bastante típica) pues para ello se llaman a servicios
del sistema operativo. También podemos activar el virus con el timer del ordenador,
y que compruebe la fecha y hora continuamente tal que en un determinado momento
actúe (no va a ser todo multiplicarse..), haciendo la perrería que su diseñador haya
pensado. Simplemente habría que hacer un residente en memoria, sobreescribir el
vector de interrupción deseado con la dirección de nuestro código, y que cuando terminase
su ejecución en vez de regresar con IRET saltara a la dirección antigua del vector
de interrupción (para que esa interrupción haga, además, lo que debe hacer). En
sistemas de 32 bits la cosa se vuelve mucho más fastidiada, y más en sistemas
multiusuario (Linux, Windows 2000) donde los permisos al usuario se dan con
cuentagotas. Esto es bueno para la seguridad y la compatibilidad (ahora mismo no hay
dos tarjetas de vídeo que funcionen igual ni por lo más remoto; no así cuando las VGA),
pero malo para el experimentador (pues le impide actuar directamente sobre el hardware,
teniendo que pasar primero por el sistema operativo para solicitar ese servicio). En la sección de enlaces del final se incluye un ZIP con un archivo de ayuda para
Windows que comprende una referencia bastante completa de las interrupciones del sistema,
tanto hardware como software (cubriendo los servicios del MSDOS, la BIOS y numerosos
controladores típicos bajo este sistema operativo). Una fuente mucho más completa
es la documentación de Ralph Brown que incluye descripciones de puertos, pinout de
componentes, especificaciones hardware... Abrumadoramente extensa, pero muy muy
recomendable.
CAPÍTULO VIII: ENTRADA/SALIDA En este capítulo se presentan los accesos de entrada/salida a dos niveles: el primero,
mediante los servicios proporcionados por el sistema operativo (en este caso MSDOS,
aunque los otros sistemas darán cobertura similar) y la BIOS (que, como se explicará,
no tiene demasiada utilidad en sistemas 32 bits); el segundo, accediendo directamente
a los periféricos a través de los puertos E/S.
FUNCIONES TÍPICAS DE ENTRADA/SALIDA Aquí incluyo algunas funciones para comunicación con el teclado y la pantalla
que permitan hacer pequeños programas bajo MSDOS a fin de experimentar un poco
con el ensamblador. Los argumentos a las funciones se pasan en los registros
indicados. Como luego se verá es mejor dejar estas faenas al lenguaje C, que es
bastante más flexible (de hecho algo tan básico como la entrada de reales a través
de teclado en ensamblador es mortal, cuando en C es una trivialidad; como se suele
decir, no tratemos de reinventar la rueda). ENTRADA/SALIDA A TRAVÉS DEL MSDOS La interrupción del MSDOS para todos estos servicios es la 21h; para
llamar a una función sólo tenemos que introducir en los registros los argumentos
necesarios, y llamar al sistema mediante INT 21h. AH=6h AH=9h AH=1h AH=7h AH=6h AH=0Ah El MSDOS proporciona también servicios de acceso a archivos (para listar los archivos
que hay dentro de un directorio, cambiar de directorio, leer o escribir en ellos),
solicitud de memoria en tiempo de ejecución (lo que hace malloc en C no es más que
pedir memoria al sistema operativo), finalización del programa.. Es por ello importante
que un sistema operativo esté fuertemente documentado, a fin de que los programas
realizados para él aprovechen al máximo sus posibilidades. Un excesivo hermetismo al
respecto desequilibraría la carrera del desarrollo de aplicaciones para ese entorno a
favor de su creador (como ya está sucediendo con uno bien conocido y usado por
muchos). ENTRADA/SALIDA A TRAVÉS DE LA BIOS La BIOS proporcionaba mayor flexibilidad que el DOS para gestionar la salida, pues
cubre puntos como la reubicación del cursor en la pantalla, los atributos del texto,
cambiar la ventana activa (en modo texto, por ejemplo, como el contenido de la pantalla
ocupa sólo 4kb, es posible tener varias "imágenes" almacenadas simultáneamente en la
memoria de vídeo, y cambiar de una a otra en cualquier momento), acceso a modos
gráficos.. A todas estas funciones se accede con INT 10h. Es posible realizar las
operaciones de entrada/salida arriba mencionadas (y otras) para el MSDOS con la BIOS,
pero se omiten por su escaso interés.
El amigo de los niños
Operación
ST
ST(1)
ST(2)
1
Empujo el 4
4
2
Empujo el 2
2
4
3
^
16
4
Empujo el 3
3
16
5
Empujo el 2
2
3
16
6
^
9
16 7
+
25
8
raíz
5
FADD [memoria]; ST = ST + [memoria], memoria ha de ser un real.
FADD ST, ST(n); ST = ST + ST(n)
FADD ST(n), ST; ST(n) = ST(n) + ST
Memoria ha de ser un entero (complemento a dos)
FSUB [memoria]; ST = ST - [memoria], memoria ha de ser un real
FSUB ST(n), ST; ST(n) = ST(n) - ST
FSUB ST, ST(n); ST = ST - ST(n)
Memoria ha de ser un entero (y de aquí en adelante, salvo que diga otra cosa,
-y casi nunca se dirá otra cosa- complemento a dos)
FMUL [memoria]; ST = ST*[memoria]
FMUL ST(n), ST; ST(n) = ST(n)*ST
FMUL ST, ST(n); ST = ST*ST(n)
FDIV [memoria]; ST = ST / [memoria]
FDIV ST(n), ST; ST(n) = ST(n)/ST
FDIV ST, ST(n); ST = ST / ST(n)
Servicios del MSDOS y la BIOS. Los puertos E/S.
DL=código ASCII del carácter (excepto 0FFh)
DS:DX=dirección de la cadena
Imprimirá todos los bytes a partir de DS:DX según su código ASCII, excepto el carácter
'$' que indicará el final de la cadena.
La función devuelve en AL el código ASCII del carácter leído; si la entrada estándar
es el teclado, esperará a que se pulse una tecla antes de regresar.
La función devuelve en AL el código ASCII del carácter leído; si la entrada estándar
es el teclado, esperará a que se pulse una tecla antes de regresar.
DL=0FFh
Si había un carácter en el buffer ZF=0, y AL=código del ASCII leído; en caso contrario
ZF=1.
DS:DX=dirección de memoria donde se copiará la cadena
Va leyendo caracteres hasta que se pulsa INTRO o, si la entrada estándar es un archivo,
encuentra el par de caracteres 0Dh,0Ah, que en MSDOS/Windows corresponde con un paso de
línea. El primer byte de la zona de memoria reservada para la cadena contendrá el
número máximo de caracteres a leer, en el segundo se escribirá el número de caracteres
leídos, y en los siguientes la cadena.
Se incluyen aquí únicamente unas pocas funciones de salida para complementar las anteriores
AH=2
BH=página a la que cambiar esa posición
DH=fila
DL=columna
La esquina superior izquierda corresponde con el punto (0,0), y la inferior derecha
(79,24)
AH=3
BH=página de la que obtener la posición
La posición se devuelve en DX; DH=fila, DL=columna
AH=5
AL=página activa
AH=6
AL=0
BH=atributo
CX=fila|columna de la esquina superior izquierda
DX=fila|columna de la esquina inferior derecha
Llena la ventana rectangular delimitada por CX,DX con espacios del atributo indicado
ACCESO A LOS PUERTOS DE ENTRADA/SALIDA
Aunque el sistema operativo y la BIOS nos proporcionan bastantes servicios de acceso al hardware, a menudo son insuficientes. Dos casos en donde tradicionalmente ha habido que pegarse directamente con el hardware han sido los accesos a vídeo y a los puertos serie. Aunque la BIOS tiene funciones bastante flexibles al respecto, las rutinas de vídeo son horriblemente lentas (lo que ha hecho que prácticamente nunca se hayan usado para programar videojuegos); por otra parte las funciones del puerto serie son algo viejillas, y sólo se puede configurar hasta 9600 baudios (lo cual es insuficiente en muchos casos)
Como en el 68000 (y muchas más máquinas), se accede a los periféricos a través de unos registros ubicados en direcciones asociadas a ellos llamadas puertos. Contaremos con registros de datos para transferencias entre el dispositivo y el micro, de estado para leer, obviamente, su estado, y de control para modificar el comportamiento de ese periférico.
Todos los puertos son de 8 bits, aunque es posible escribir y leer datos de mayor tamaño siguiendo la ordenación Intel, efectuándose la operación sobre el puerto indicado y los puertos siguientes a éste.
Las instrucciones básicas para acceder a los puertos se denominan IN y OUT, de traducción bastante evidente. El sentido de las instrucciones se refiere desde el punto de vista del micro: OUT es hacia el puerto, IN hacia el micro.
Escribe en el puerto destino el dato en origen (lógico, ¿no?). El origen es el acumulador (AL/AX/EAX) y el destino, el puerto. Si se especifica el puerto en valor inmediato ha de ocupar un byte (0-255); para acceder a un puerto cualquiera se especificará DX como operando origen, conteniendo este registro el número del puerto deseado.
Ahora el destino es el acumulador, pues queremos traer un dato del puerto hacia el micro; origen será un valor inmediato 0-255 o DX.
Supongamos que queremos poner todos los bits de datos del puerto paralelo a 1; por lo general el primer puerto paralelo está asociado a la dirección 378h. Como es un valor que ocupa más de un byte, hemos de usar DX como registro que apunte al puerto:
mov dx,378h;
mov al,0FFh;
out dx,al;
Tras esta operación (suponiendo que el puerto esté configurado como salida, para lo cual habría que enredar un poco con el registro de control) los pines 2-9 del puerto paralelo estarán a nivel alto, 5v.
De esta manera podríamos acceder al ratón a más bajo nivel que mediante el driver del fabricante, a la tarjeta de vídeo (para cambiar la paleta, por ejemplo), al joystick, a la tarjeta de sonido... Todo se reduce a leerse el "manual de instrucciones" del aparatejo en cuestión, y empezar jugar con IN/OUT como un loco. Para esto quizá nos fuesen de utilidad las instrucciones de cadena asociadas a IN y OUT, INS y OUTS.. Éstas se las dejo al que tenga curiosidad :P
Actualmente cada dispositivo viene con un driver propio que gestiona las operaciones de E/S directamente, dado que la amplitud del mercado es difícilmente abarcable.
CAPÍTULO IX: EL MODELO DE MEMORIA EN MSDOS Como bien dice el título, este sistema está obsoleto. Los Windows no van así, los
Linux no van así. Lo incluyo por culturilla, y porque yo he usado MSDOS una buena
temporada (pegándome con la configuración de inicio para rascar esos kbytes de memoria
convencional extra para conseguir ejecutar un juego). Y porque cada vez hay más gente
que se anima con el mal llamado abandonware, o juegos "clásicos" ("viejos", dirá
algún desalmado xD), que a menudo dan muchos problemas con los gestores de memoria.
Bueno, qué demonios, que el saber no ocupa lugar. Si alguien se aburre que se lo salte.
Los primeros 640kb de memoria RAM comprenden la memoria convencional. Ahí
se mete parte del sistema operativo (casi todo, en los anteriores al 286; me refiero a
lo que es el sistema operativo en sí, no las aplicaciones que incluye) y es donde se
cargan (aunque hable en presente me refiero siempre al MSDOS) en su mayoría los programas.
Se llama memoria superior a los 384kb de memoria direccionable que quedan
por encima de la convencional (640+384=1024k=1Mb). Sobre ella se mapea la memoria de
vídeo, la memoria expandida y la BIOS. Dentro de la memoria superior los segmentos 0A000h y 0B000h se reservan para la
memoria de vídeo. Ahí mapea la tarjeta gráfica su memoria; como se vió en el ejemplo
de "hola mundo", escribiendo en esas direcciones se escribe realmente sobre la tarjeta
de vídeo. El significado del contenido en estos dos segmentos depende del modo gráfico
en el que estemos (resolución y profundidad de color), así como de la tarjeta. Ésta lo
que hace es recorrer continuamente su memoria y proporcionar una salida analógica tras
DAC's con las componentes de color junto con unas señales de sincronismo. Con el acceso
a los puertos es posible elegir qué parte de la memoria se mapea en la RAM, pues las
tarjetas pronto empezaron a contar con más de 64kb de memoria. Se dice entonces que
la memoria está paginada, siendo ese segmento su marco de página; estas
operaciones eran habituales en las SVGA. En el segmento 0C000h se encuentra parte de la BIOS y residentes que el MSDOS
ubicaba en el arranque para liberar memoria convencional. Los segmentos 0D000h y 0E000h
de memoria superior pueden ser ocupados también por la BIOS o más residentes; sin
embargo son importantes porque se puede mapear en uno de ellos (generalmente 0D000h)
la memoria expandida. En 0F000h se coloca la ROM BIOS (el SETUP, por ejemplo) Aquí uno solía decir; vale, pero yo tengo instalada más memoria. ¿Qué pasa con ella?
Toda la memoria por encima del mega direccionable (directamente, se entiende) se llama
memoria extendida. Los primeros 64k eran accesibles sin demasiado problema
habilitando una línea adicional del bus, pues como se comentó al calcular la dirección
a partir del segmento y el offset podía suceder que tuviéramos bit de carry. Aprovechando
esta característica se accede a la memoria alta, que aparece en los 286 y
posteriores. Mediante funciones especiales es posible copiar bloques de memoria extendida
sobre la memoria convencional, y viceversa. De esta manera es posible acceder a los datos
más rápidamente que cargándolos desde el disco; nacieron con ello los discos virtuales o
cachés de disco (la idea opuesta a la memoria virtual) para determinadas aplicaciones.
El estándar que surgió de todo esto fueron las especificaciones XMS, que además
hacían más rápidas las operaciones de memoria en los 386+ (haciendo uso del ancho de
32 bits). Ya en los tiempos el XT la gente había empezado a darle al tarro para manejar más
memoria. La idea era paginar la memoria RAM (como la tarjeta de vídeo) usando un segmento
como marco de página, que se dividió en 4 bloques de 16 kb. Se incluyó memoria RAM con
circuitería especial y mediante accesos E/S se elegía qué página se colocaba sobre
esa "ventana". Para facilitar la gestión de esta memoria se desarrolló la especificación
LIM-EMS (Lotus-Intel-Microsoft Expanded Memory System), un driver que se cargaba en el
inicio y accesible a través de la interrupción 67h, haciendo que el programador se
pudiera olvidar del control directo de la tarjeta de memoria. Los 386+ podían "convertir" memoria extendida en expandida a través de un gestor
de memoria como el EMM386 (incluído con el MSDOS) o QEMM386. En el arranque el sistema
entraba en modo protegido para tener completo control de la memoria y a continuación
entraba en modo virtual, emulando en entorno DOS la memoria expandida. En el Universo
Digital de la sección de enlaces se explica el manejo tanto de la memoria extendida
como de la expandida (por si alguno se aburre). Esta distribución de la memoria tiene gran interés para el programador a bajo nivel,
incluso en modo protegido. Aunque en este modo la memoria se direcciona con total
libertad (por lo menos para el sistema operativo), es importante saber que ese mapa
de memoria está ahí realmente, aunque no se obtengan las direcciones de esa
manera las posiciones absolutas son las mismas; hay zonas con ROM mapeada que no se
deben tocar, la tarjeta de vídeo sigue en un determinado segmento, etc. Desde ese
punto de vista el espacio de memoria no es algo conexo, cosa que no habrá que olvidar
si se va a programar un sistema "desde cero".
CAPÍTULO X: SISTEMAS DE 32 BITS MODO PROTEGIDO Ya se comentó al principio de todo, muy brevemente, en qué consiste el modo
protegido. Aquí explicaré por encima qué se debe tener en cuenta al programar
en un sistema en modo protegido. Es importante ver que el estar en modo protegido
no implica en sí mismo un mecanismo de protección; éste lo debe proporcionar el
sistema operativo. Es perfectamente posible hacer una aplicación para MSDOS que
entre por sí sola en modo protegido (de hecho las ha habido, y muchas) y salga de
él tras su ejecución, pero eso no quita para que el sistema operativo siga siendo
altamente accesible a muy bajo nivel y, por tanto, vulnerable. Intel define la administración de memoria de los 386+ fundamentalmente en dos
características: segmentación y paginación. La segmentación es una necesidad en
tanto que proporciona una organizada distribución de la memoria disponible en un
entorno multitarea; en modo protegido cada segmento tiene un nivel de acceso (entre
otras cosas, se puede definir un segmento como de lectura o lectura/escritura), y cada
programa tiene su(s) segmento(s) asignado(s). Toda la memoria del ordenador es
direccionable linealmente; sin embargo no se accede a ella directamente de esta manera.
Cada segmento tiene asociado un puntero dentro de este espacio, una dirección
física que indica dónde está ubicado realmente en la memoria. El
registro de segmento ya no indica la posición en memoria del segmento, sino que se dice
que es un selector de segmento. La dirección vista desde el programa es en
realidad una dirección lógica, tiene asignado un segmento y un desplazamiento
dentro del segmento. Cuando se accede a un segmento el circuito que gestiona la memoria
localiza el puntero que le corresponde sobre el espacio lineal y desplaza el
puntero tanto como indique el offset. En todo momento el sistema comprueba que el puntero
se desplace únicamente dentro de los límites de memoria asignados a ese segmento. Como
vemos es mucho más complejo que en el caso de segmentación de 16 bits (pues exige llevar
una tabla con los segmentos, el rango que ocupan en la memoria, accesos...), pero es
también muchísimo más potente en cuanto a capacidad de organización y control. La otra característica, la paginación, puede estar o no presente (depende de si
la aprovecha el sistema operativo). Si no está activa, la dirección generada a partir
de la asignada al segmento corresponde efectivamente con la dirección física. Sin
embargo es frecuente que se maneje más memoria que la que está realmente instalada en
el equipo (uso de memoria virtual). Aquí entra en acción la paginación. Cuando se usa
la paginación se divide cada segmento en bloques más pequeños (al fin y al cabo un
segmento puede ser MUY grande). Cuando se accede a una zona de esa memoria lineal
disponible, se intenta leer en la RAM tras traducir esa dirección a la dirección física.
Si no existe, lo que hace es generar una excepción de fallo de página, para que
el sistema acceda a disco y copie ese bloque (y tal vez otros más, ante la previsión
de que puedan ser accedidos también) en RAM para que se intente leer por segunda vez.
Incluso una máquina virtual 8086 en este entorno puede encontrarse paginada de esta
manera sin ningún efecto visible desde su propio punto de vista. Por supuesto tanto
Linux como Windows hacen uso de la paginación. Por si a alguien le interesa el tema y se mira los enlaces relativos al funcionamiento
del modo protegido (la System Programming Guide de Intel es una pasada) para ver
el asunto en profundidad, le diré para mi descargo que lo que he contado aquí se asemeja
a relatar el desembarco de Normandía con los recursos literarios de "los tres cerditos".
Lo digo, más que nada, para que si alguien en vez de un registro de segmento ve cuatro
registros, no se asuste. Por cuestiones prácticas no es posible llamar a la BIOS desde estos sistemas
operativos. De hecho el asunto de las interrupciones se complica notablemente y,
como ya hemos dicho, tampoco vamos a poder enredar con los vectores de interrupción.
Los puertos de E/S estarán asimismo fuertemente restringidos, y está claro que
con este modelo de memoria no podemos acceder a la RAM de vídeo con tanta alegría
(cuando escribimos en la memoria de vídeo en el primer ejemplo, no fue realmente ahí
donde lo hicimos; fue el sistema operativo quien, dependiendo de si estábamos
ejecutando a pantalla completa o en ventana, accedió al vídeo convenientemente)
Estas últimas operaciones las gestionará por lo general el sistema operativo, de modo
que será él quien nos proporcione los servicios de acceso. Por cierto, cuando
hacemos un CLI en modo protegido, por lo general el sistema se partirá de risa
ante nuestra ingenuidad, y nos dirá algo así como "nononono". Cuando un programa
DOS corriendo en VM86 hace algo de esto, Windows hace un apaño para que las
interrupciones no alteren el funcionamiento de aquél, pero seguirá gestionándolas
"de fondo" sin ningún miramiento. INTERACCIÓN C & ENSAMBLADOR El ensamblador se convierte en una herramienta interesantísima (para mí, claro)
cuando se usa combinando código con C. Toda la gestión del programa que podría
resultar soporífera y repetitiva (como la comunicación con el usuario) en ensamblador
se puede realizar de manera muy sencilla en C. Existen multitud de librerías
de acceso a gráficos, sistema archivos, hardware en general.. para C, por lo que
podemos dejar el "trabajo sucio" de manejar los datos al ensamblador. Todo lo que
necesitamos saber es cómo se almacenan las variables, cómo se llama a una función,
y qué nos devuelve.
Vamos a ver las variables típicas en C. Una variable de tipo char es un byte.
Un int es típicamente un dword, 32 bits. Un float es un número de
coma flotante de precisión simple, 32 bits. Un double son 64 bits. Un puntero
será típicamente de 32 bits, un puntero cercano (en un sistema de 32 bits),
pues por lo general todos los datos estarán ubicado en el mismo segmento, por defecto
DS (no sé bien, pero yo creo que en 4Gb hay sitio para un regimiento). Las funciones se comunican entre sí a través de la pila. Cuando se llama a una
función el programa introduce los argumentos en la pila en orden inverso al dado
en la definición. A continuación, como en toda llamada que se precie, empuja la
dirección de retorno (4 bytes, suponiendo llamadas intrasegmento) y salta a la
dirección de la función. Suponiendo que al otro lado del teléfono estemos esperando
en ensamblador, haremos la típica triquiñuela de empujar EBP y copiar ESP en EBP
(con un "enter N,0" matamos dos pájaros de un tiro), y ale, ya podemos acceder
a los datos con tranquilidad. Estamos dentro de la función en ensamblador. ¿Tenemos restricciones? Sí.
Al finalizar la rutina tenemos que dejar como están EBP,ESP,DS,SS,ESI y EDI.
Si los queremos usar, los metemos en el saco para luego dejarlos como estaban. ¿Cómo devolvemos los resultados (si es que los hay)? Los resultados de tipo entero
en el acumulador: char en AL, puntero e int en EAX (creo que los short
int se almacenan en una palabra, pero tendré que probarlo). Los resultados en coma
flotante se pasan a través de la pila de la FPU, en ST (y siguientes, si los hubiera).
Las estructuras no son más que tipos básicos empaquetados; si causan mucho problema se
pueden pasar por referencia. Cosas más complicadas (el C++ funciona igual, y ahi ya
montas lo que te dé la gana) no tengo ni idea. Eso ya cada uno que experimente lo que
quiera. Hay que aclarar que lo mencionado es un caso típico. Cada compilador C puede tener
alguna diferencia, para lo cual habrá que leerse la documentación del programa en
cuestión y buscar algo llamado "calling conventions" (si no eso, algo parecido).
Con esto sabido, sólo hay que declarar el nombre de la función como externo para que
el enlazador la busque en otro archivo objeto y no en el propio módulo en C, y en
la función en ensamblador declarar el procedimiento como público (accesible desde
el exterior), generalmente con la directiva PUBLIC. Por lo general habrá que poner un
guión bajo ("_") delante del nombre del procedimiento en ensamblador que dimos en el
módulo en C. De nuevo habrá que echar un ojo a la documentación del software. Vamos con un ejemplo. El programa hará una llamada a una función hecha en ensamblador
que realice una rotación de un vector de dos componentes un ángulo dado en radianes. El
vector se pasará por referencia y el ángulo por valor. La función devolverá el ángulo que
forma el vector con el eje x tras la rotación. A la vuelta de la función el programa
imprimirá el resultado en pantalla. Para el C usaré el compilador gratuito de Borland
BCC 5.5 (que es en realidad un compilador de C++), mientras que el ensamblado lo haré con
TASM (con directivas de segmento alternativas a las usadas para el "hola mundo")
indicando que el procedimiento es de 32 bits
Brevísimas anotaciones sobre un sistema obsoleto.
Modo protegido. Interacción C&Ensamblador.
El programa en C es el siguiente:
#include <stdio.h>
#define PI 3.14159265
struct vector{
double x;
double y;
};
extern "C" double rota(vector *v, double alfa); //definicion de la funcion externa para C++
main( ){
vector v;
v.x=0; v.y=1 //vector (0,1)
double alfa=PI/3; //rotar 60º en sentido antihorario (positivo)
printf("Vector: (%f,%f)\n\n",v.x,v.y);
printf("Angulo de rotacion: %f grados\n\n",alfa/PI*180);
alfa=rota(&v,alfa);
printf("Vector rotado: (%f,%f)\n",v.x,v.y);
printf("Angulo que forma el vector con el semieje x positivo: %f",alfa/PI*180);
}
Y el módulo en ensamblador:
.386
.387
V EQU EAX ;definiciones para hacer más legible el código
X EQU 0
Y EQU 8
Alfa EQU EBP+12
CODIGO SEGMENT 'CODE' USE32 ;segmento de código de 32 bits
PUBLIC _rota ;permite que el procedimiento _rota sea accesible desde el exterior
_rota PROC
enter 0,0
mov V, [ebp+8] ;apunta con EAX a las componentes del vector
fld qword ptr [Alfa] ;carga el ángulo
;operaciones para la rotación
fsincos
fld qword ptr [V.X]
fld qword ptr [V.Y]
fld st(1)
fld st(1)
fmul st,st(5)
fxch
fmul st,st(4)
fsubr
fxch st(2)
fmul st,st(4)
fxch
fmul st,st(3)
fadd
fst qword ptr [V.Y] ;almacena nuevo V.Y
fxch
fst qword ptr [V.X] ;almacena nuevo V.X
fpatan ;el ángulo actual del vector con la horizontal en ST(0) listo para ser recogido por el programa principal
ffree st(1) ;elimina los elementos por debajo de ST(0)
ffree st(2)
leave
ret
_rota ENDP ;fin de procedimiento
CODIGO ENDS ;fin de segmento
END ;fin de programa
Si suponemos que hemos guardado el primer programa como codigo.cpp y el segundo
como funcion.asm, el procedimiento a seguir es el siguiente:
tasm /mx funcion.asm
bcc32 codigo.cpp funcion.obj
El parámetro /mx indica al ensamblador que respete la diferente entre mayúsculas y minúsculas
pues por defecto omite esa distinción, cuando C siempre la hace. En la segunda línea se
dice a BCC que compile codigo.cpp y en la fase de enlazado añada el módulo funcion.obj
que creó TASM al enlazar. En realidad BCC32.EXE solamente compila, pero llama a ILINK32.EXE
para que enlace automáticamente. A menudo habrá que enlazar "manualmente" los módulos
con el programa correspondiente pues el compilador únicamente nos crea un archivo objeto.
Se ha empleado una sintaxis distinta para los direccionamientos en ensamblador. TASM admite en estos casos el uso de "." en lugar de "+" de manera que, una vez definidas las equivalencias con palabras clave, podemos emplear una sintaxis muy parecida a la del C para acceder a las estructuras.
La directiva SEGMENT declara la organización de un segmento. Le acompaña una cadena denominada clase del segmento: 'CODE' para código, 'DATA' para datos, 'STACK' para la pila. Un ejemplo de segmento de pila:
PILA SEGMENT STACK 'STACK'
DW 1024 DUP (?)
PILA ENDS
En el caso de la pila es necesario además usar la directiva STACK (creo que una la usa el ensamblador y otra el linker o algo así). USE32 ó USE16 (por defecto) especifican qué tipo de código contiene el segmento. PUBLIC puede usarse para todo un segmento o en cada procedimiento para permitir que otros módulos accedan a las partes englobadas por la directiva; PRIVATE es la directiva opuesta (impide el acceso externo).
Otra manera muy amena aunque un poco menos elegante de incorporar código en ensamblador dentro de C es mediante la directiva asm que admiten muchos compiladores, y que sirve para introducir instrucciones en ensamblador dentro del listado en C:
main( ){
double x=1;
asm{
fld x
fcos
fstp x
}
//ahora x vale cos(1)
}
El no usar corchetes [ ] para referirse a direcciones dadas por etiquetas es también válido con TASM, aunque yo los utilizo porque me parecen más claros. El compilador en en este caso sabe que se tratan de variables double por lo que no es necesario el uso de typecast.
A cuento de las etiquetas cabe comentar una directiva de cierto interés: ASSUME. Esta directiva colocada justo debajo de la "apertura" de un segmento de código especifica que asuma unos determinados registros como prefijos de segmento cuando se refiera a etiquetas. Por ejemplo:
CODIGO SEGMENT 'CODE' PUBLIC USE16
ASSUME CS:CODIGO, SS:PILA, DS:DATOS
CODIGO ENDS
Cuando dentro de ese segmento me refiera a una etiqueta del segmento DATOS, el ensamblador colocará por defecto DS como prefijo de segmento. Por supuesto nosotros antes de hacer tal referencia nos habremos asegurado de que DS, efectivamente, apunte a DATOS. Es una manera sencilla de, en el caso hipotético de varios segmentos de datos (más comunes en programación de 16 bits), no andar buscando las variables por los segmentos.
CAPÍTULO XI: MI ORDENADOR TIENE MMX. Y QUÉ. Todos los Pentium MMX y posteriores procesadores Intel, así como prácticamente todos los
AMD desde su fecha cuentan con un juego de instrucciones con este nombre. Estas instrucciones
trabajan con la filosofía SIMD (que como ya dijimos viene a ser aplicar la misma operación a
varios datos), que suele ser de aplicación en imágenes, vídeo y cosas así. Para hacer uso de
lo aprendido en este capítulo hará falta un ensamblador mínimamente actualizado (el
TASM 5.5 creo que aceptará estas instrucciones, no así el 5.0), y aunque es un
tema de aplicación bastante específica, no es algo que se les de muy bien a los
compiladores: muchos no lo usan, y otros no lo usan demasiado bien. Para evitar
jaleos con al sintaxis entre ensambladores, me limitaré a mencionar los efectos
de cada instrucción. Este tipo de micros (Pentiums MMX y posteriores) cuentan con 8 registros MMX de 64
bits, que en realidad son un alias (bueno, esto pronto dejó de ser así, pero
en principio es cierto) de los registros de la FPU. Quiere decir esto que cuando escribo
un dato en un registro MMX en realidad lo estoy haciendo sobre un registro del
coprocesador. Más exactamente, sobre la mantisa de un registro del coprocesador. Esto
requiere que cada vez que se alternen operaciones de coma flotante con MMX, como cuando
se cambia de contexto en un entorno multitarea, se salve el estado de los registros
de un tipo y se restaure del otro. Por tanto, aunque es posible emplear instrucciones MMX
y de la FPU en un mismo código, es poco recomendable en tanto que cambiar constantemente
de unas a otras dará un programa muy ineficiente. Intel recomienda en estos casos algo
que es tan de sentido común como efectuar la ejecución de estas instrucciones de manera
separada en ciclos, subrutinas, etc.. vaciando completamente los registros de un tipo u
otro en cada cambio (y no dejar por ejemplo un real en la pila de la FPU antes de entrar
en una función MMX para luego esperar que ese número siga ahí). Si alternamos código FP
(floating point, para abreviar) con MMX, al acabar la parte FP se habrá vaciado
completamente la pila (con FFREE, mismamente), se ejecutará el código MMX y se terminará
con la instrucción EMMS (dejándolo libre de nuevo para instrucciones FP) y así
sucesivamente. Los registros MMX se nombran por las posiciones reales que ocupan (MM0,MM1...MM7), y no
por la posición respecto de un puntero de pila como en la FPU. A estos ocho registros puede
accederse con los modos de direccionamiento habituales, con lo que a ese respecto no
tendremos que aprender nada nuevo. TIPOS DE DATOS Los registros son de 64 bits, que pueden distribuirse de distinta manera para
representar la información. Tenemos por tanto paquetes de bytes (8 bytes por registro),
palabras (4 words), palabras dobles (2 dwords) o cuádruples (todo el
registro para un único dato de 64 bits, una qword) ¿Dónde está la gracia del asunto? Pues en que ahora podemos, por ejemplo, sumar 8
bytes con signo con otros 8 bytes con signo si están debidamente empaquetados en dos
registros, ya que el micro efectuará la suma de cada byte de un registro con el byte
correspondiente del otro, todo ello en paralelo. Esto se aplica para operaciones
aritméticas y lógicas. Por ejemplo, si tenemos 2 registros MMX con datos de tipo byte
sin signo "FF 18 AB C1 01 14 00 F0" y "81 9F 03 01 A1 BB 12 11" y los sumamos con
PADDB obtendremos como resultado la suma de cada byte individual con cada byte:
"80 B7 AE C2 A2 CF 12 01" SATURACIÓN Y RECICLADO. Traducciones raras para conceptos simples. Intel distingue dos modos de operación aritmética: saturating arithmetic y
wraparound. Normalmente cuando se efectúa una suma, si el resultado excede el
rango de representación aparece un bit de carry que va a algún sitio, o se pierde,
y tenemos como resultado la parte baja del resultado "bueno". Esto es lo que se
llama wraparound. Por el contrario, se realizan operaciones que saturan
cuando un número que se sale del rango válido por arriba por abajo es limitado (como la
palabra misma sugiere) al máximo o mínimo representable. Por ejemplo, para números sin
signo, F0h + 10h no será 00h (y me llevo una), sino FFh. Dependiendo de si son números
con o sin signo los rangos serán diferentes, claro. Si tenemos una señal de audio
muestreada con 16 bits (enteros con signo) y por lo que sea la amplitud ha de
incrementarse, si la señal valiese ya 7FFFh en una determinada muestra, el overflow
nos la destrozaría pues invertiría ahí la señal (8000h = -32768d). Y lo mismo sucede
con el color (si FFh es el máximo de intensidad posible de un color y 00h el mínimo,
oscurecer un poco más una imagen a todos los pixels por igual convertiría el negro,
00h, en blanco puro, FFh). En situaciones de este tipo las operaciones ordinarias son
muy engorrosas pues exigen numerosas comprobaciones, cuando el concepto de operaciones
con saturación nos resuelve inmediatamente la papeleta. Si te cuento todo este rollo es porque, efectivamente, hay instrucciones MMX con ambos
modos de operación. JUEGO DE INTRUCCIONES MMX Todas las instrucciones MMX (excepto EMMS) tienen dos operandos, destino y origen,
y como siempre en ese mismo orden. El operando origen podrá ser memoria o un registro
MMX, mientras que el destino siempre será un registro MMX (excepto en las operaciones de movimiento,
claro, porque si no, apañados íbamos). Bueno, que empiece la fiesta. - INSTRUCCIONES DE MOVIMIENTO DE DATOS
Copia una palabra doble (32 bits) de origen en destino. Cualquiera de los
operandos puede ser un registro MMX, memoria, o incluso un registro de propósito general
de 32 bits. Sin embargo no es válido mover entre registros MMX, entre posiciones de
memoria, ni entre registros de propósito general. Si el destino es un registro MMX
el dato se copia a la parte baja y se extiende con ceros. Si el origen es un registro
MMX se copia es la parte baja de dicho registro sobre el destino.
A partir de ahora, y salvo que se diga lo contrario, todas las instrucciones admiten como operandos origen registros MMX o memoria, y como destino obligatoriamente un registro MMX.
- INSTRUCCIONES ARITMÉTICAS
- INSTRUCCIONES DE COMPARACIÓN
- INSTRUCCIONES LÓGICAS
- INSTRUCCIONES DE DESPLAZAMIENTO
EMMS (Empty MMX State) restaura el estado de los registros para que puedan ser usados por la FPU, pues cada vez que se ejecuta una instrucción MMX se marcan todos los registros como con un resultado numérico válido, cuando para su uso por la FPU han de ser marcados como vacíos (que es exactamente lo que hace FFREE)
APLICACIONES
¿Realmente son tan útiles estas instrucciones? Pues sí. Aparte de la aplicación obvia de mover datos en memoria en tochos de 64 bits (cosa no demasiado recomendable mediante instrucciones de la FPU, pues pierde tiempo haciendo conversiones), como ya se dijo, en tratamiento de imágenes tenemos que manipular regiones de memoria bastante generosas, a menudo con operaciones repetitivas. Como es probable que al que no haya hecho nada parecido en su vida no se le ocurra ninguna aplicación, vamos con un ejemplillo con dibujos y todo.
He aquí dos imágenes de un juego que hipotéticamente estamos programando. La de la izquierda corresponde a un decorado estilo años 30, en blanco y negro y todo, y la de la derecha a nuestro protagonista (que a más de un amante de las aventuras gráficas le resultará sobradamente conocido). Las imágenes son almacenadas en 256 tonos de gris, o sea, un byte por pixel; cada imagen es una matriz de enteros, cada byte indicando el brillo (255 blanco puro, 0 negro puro). Nuestra idea es que el personaje se mueva por la pantalla, alternando imágenes en sucesión sobre el decorado. Así pues el primer objetivo es colocar UNA imagen del personaje sobre el fondo.
Fácil, cogemos la imagen del personaje y la pegamos sobre.. Ummm. Es posible que el fondo negro sea un problema. Bueno, entonces miramos byte a byte; si es negro (0) dejamos el fondo como está, y si no lo es sobreescrimos el fondo con el byte correspondiente de nuestro personaje. Podemos asignar entonces al 0 el color "transparente", y distribuir nuestra paleta de grises del 1 al 255 (pues los ojos son negros, y habría que asignarles un color distinto de 0).
No es una mala opción, pero al menos hay otra que es crear lo que se conoce como una máscara. Con cada imagen del personaje almacenamos otra de "negativo", que contiene FFh donde NO hay pixels del personaje, y 00h donde sea así. Si llamamos a F el fondo, P el personaje y M la máscara, tenemos que F_nuevo = [F AND M] + [P AND NOT(M)] (asumiendo por supuesto que asignamos los elementos correctos de cada matriz). Las operaciones lógicas las podremos hacer a toda leche cogiendo los bytes de 8 en 8 con nuestras instrucciones MMX. Puede parecer un poco retorcido en comparación, pero ya veremos que tiene sus ventajas.
Seguimos con el ejemplo. Supongamos ahora que tenemos almacenados los "fotogramas" del movimiento de nuestro intrépido protagonista, pero que al emplearlos en un entorno como el anterior (de noche) vemos que queda demasiado claro: no "pega" la luminosidad que tiene esa imagen con la del fondo. Y más aún, queremos que según nos movamos por la pantalla a puntos más claros (bajo una farola) el personaje se ilumine un poco, y se oscurezca al meterse en zonas sin iluminación. Aquí entra en acción la suma con saturación. Cogemos la imagen del personaje, y sumamos o restamos un valor dado (en función de la luminosidad) a los atributos de color de la matriz. Supongamos que un punto es blanco (FFh) y por efecto del "aclarado" le sumamos 1 más. Gracias a la suma con saturación permanecería FF, pudiendo operar indistintamente con todos los elementos de la matriz. Ahora nos interesa el asunto de la máscara, pues el 00h sería el tope inferior de "oscuridad". Cambiando un simple parámetro podríamos generar con suma facilidad, y en tiempo de ejecución, degradados comos los siguientes:
Si imaginamos ahora lo que tendríamos que hacer para realizar todas esas sumas, comprobaciones, operaciones lógicas prácticamente byte a byte mediante instrucciones convencionales, es obvio que el factor de ganancia es incluso mayor que ocho. ¿Convencido?
Por último, unas notas sobre los compiladores. En la mayoría de los lenguajes de alto nivel no es posible pasar argumentos a través de registros MMX, cosa a tener en cuenta en un posible interfaz LAN-ensamblador. No es en cualquier caso demasiado grave, pues lo más habitual en esta situación será pasar los argumentos por referencia, esto es, pasando un puntero al dato.
Otro punto, quizá de mayor importancia, es que podemos encontrarnos compiladores que no hagan uso de las instrucciones MMX. Normalmente existen numerosas librerías que realizan funciones específicas que demandan operaciones de este tipo (manipulación de audio, gráficos..) que están debidamente optimizadas para los nuevos procesadores. En general será buena idea documentarse bien sobre el tema antes de abordar el problema a la mínima directamente en ensamblador. Puede suceder que para una extraña aplicación en particular no exista ningún apoyo para C o algún otro lenguaje similar, quizá porque no tenga nada que ver con esos usos típicos. Aquí lo más aconsejable (creo yo) será realizar el programa en alto nivel, y una vez visto que funciona, ir sustituyendo las rutinas de cálculo por fragmentos en ensamblador. El ensamblador es un lenguaje fascinante para quien le guste los ordenadores y quiera comprender su funcionamiento, pero en la práctica su uso (cabal, me refiero) está más bien restringido. En muchos aspectos no compensará el esfuerzo de programar a tan bajo nivel (currarse un interfaz gráfico desde cero atenta contra la salud mental del programador, y aporta escasa ganancia). Con este tipo de instrucciones vemos una de esas posibles utilidades. En programas en los que el cálculo tiene un elevado peso ya no sólo es que no nos "fiemos" del compilador, sino que podemos encontrarnos con que explícitamente no utiliza determinadas características de nuestro procesador.
Un caso muy claro en el que puede no interesarnos la solución del compilador es en algoritmos numéricos de coma flotante que continuamente realizan la evaluación de funciones trigonométricas o trascendentes, que están implementadas en el coprocesador matemático. Lógicamente, no cabe imaginar un compilador actual que no use el coprocesador para este tipo de operaciones. Sin embargo, por la propia construcción del lenguaje, podemos encontrarnos con maniobras superfluas.
Imaginemos la operación de alto nivel z=sqrt(cos(x)+sin(y)).
Sin perder demasiada generalidad podemos suponer que el código generado coge la
variable 'x', se la pasa a la función coseno, devuelve un valor, pasa 'y' a la
función seno para que devuelva otro valor, realiza la suma de los dos valores
temporales, pasa este valor a la función raíz cuadrada, y guarda el resultado en
'z'. El programa en todo momento ha empleado la FPU para realizar las operaciones,
pero ha pasado argumentos y saltado varias veces, cada vez que
llamaba a una función. Si los argumentos y valores temporales se pasan a través
de la FPU no es en absoluto trágico (habríamos de hacer "a mano" algo parecido),
pero estamos invocando funciones de manera innecesaria, lo cual es costoso
pues introduce accesos a memoria (direcciones de retorno en la pila) y saltos.
Para resolver el asunto de un plumazo, tal y como vimos con el coprocesador,
basta algo tan sencillo como
fld [x]
fcos
fld [y]
fsin
fadd
fsqrt
fstp [z]
En ningún momento hemos manipulado la pila ni realizado operaciones de salto, limitándolo todo a los tres accesos a memoria estrictamente necesarios.
No es de extrañar que pueda suceder algo parecido con instrucciones tan específicas como las MMX y otras que veremos posteriormente. Todo esto es especialmente alarmante en compiladores "de andar por casa", pues es en temas como éste donde se ve la diferencia entre compiladores profesionales y gratuitos (bueno, eso teóricamente hablando, porque están los que se creen que por poner un entorno gráfico bonito, ya tienen derecho a poner todos los ceros que les dé la gana en la etiqueta). Si alguien tiene dudas, siempre puede escribir un par de líneas en su lenguaje de alto nivel favorito y trazar la zona de las operaciones propiamente dichas. Yo lo hice una vez con el BCC y puedo certificar que pega muchos más viajes de los que menciono arriba.
EL MÁS DIFÍCIL TODAVÍA. SSE, SSE2, 3DNOW, ENHANCED 3DNOW...
Con el Pentium III se introdujeron las llamas SSE, o Streaming SIMD Extensions (algunos finolis todavía las llaman instrucciones Katmai porque ése fue el nombre en clave dado por Intel hasta el lanzamiento), que constituyen un avance importante respecto de las MMX. Por supuesto los procesadores posteriores (Pentium IV) también las incluyen, así como los Celeron basados en Pentium III que aparecerían posteriormente (a partir de los 533MHz si no me falla la memoria)
Seguimos la misma filosofía que con la tecnología MMX, pero a lo bestia; operaciones en coma flotante. El procesador cuenta con nada menos que 8 registros adicionales, cada uno de la friolera de 128 bits. Podemos dividirlos en dos números de 64 bits o cuatro de 32, y realizar sumas, restas, multiplicaciones, comparaciones... en paralelo. La leche con colacao, vamos. Con estas cosillas la velocidad de cálculo matricial se dispara.
AMD por su parte sacó la tecnología 3DNow (anterior a la SSE, por cierto) en los K6-2 y siguientes, después de que Intel diera caña con la MMX. Los AMD con 3DNow soportan MMX y además incluyen instrucciones adicionales. Para mayor cachondeo, AMD instaló en los Athlon y posteriores las extensiones llamadas "Enhanced 3DNow", echando más leña al fuego. Las 3DNow en su conjunto proporcionan menos instrucciones que las SSE pero son, según AMD, más fáciles de usar y de equiparable rendimiento. Con todos estos avances, entre otras cosas, se permite mezclar MMX y operaciones de coma flotante sin problema (se usan registros nuevos y no alias de los de la FPU). Como es comprensible, las instrucciones SSE y 3DNow no son compatibles, lo que hace que el software tenga que detectar qué micro llevamos para emplear un código u otro (suponiendo que esté optimizado para emplear alguna de estas tecnologías, claro). Los drivers DirectX del 6.algo en adelante aprovechan (¿al máximo?) las posibilidades añadidas a ambas familias, así como las librerías Open GL, así como muchas aplicaciones. Por desgracia -hasta donde sé- las SSE son ligeramente más populares (más que nada porque se ven más Intel que AMD circulando por ahí), lo que hace posible que algunos programas "multimedia" rindan más en un micro con SSE que con otra cosa.
Cyrix, que tampoco se quiso quedar manco (aunque no es que perdieran un homoplato en el intento que digamos) incluyó en sus contrapartidas (MediaGX, 6x86, 6x86MX...) compatibilidad con MMX y alguna instrucción extra no demasiado célebre (¿alguien usa Cyrix?). Si contamos con una rara avis de éstas, salvo programando uno mismo es poco probable que le saquemos partido más allá de las MMX.
NASM admite instrucciones MMX, 3DNow, SSE (hasta SSE2, que todavía no he tenido el gusto de inspeccionar porque no tengo un equipo que de la talla, o sea, Pentium IV) e incluso las instrucciones que añadió Cyrix (para que no dijeran que sólo se limitaban a copiar, supongo). Es por ello que con el tiempo iré actualizando todo el tutorial para la sintaxis del NASM (que tiene versiones para DOS, Windows y Linux). Tan pronto lo haga, introduciré en esta sección las instrucciones SSE (que son las únicas que puedo probar de las citadas, aparte, claro, de las MMX).
Nuevamente surge la necesidad de usar programación en ensamblador para aplicaciones específicas donde no podamos sacar partido de las librerías optimizadas para los nuevos micros (si no es así, difícilmente un compilador podrá saber dónde usar operaciones SIMD; o sea, que no las usará). Si nos quedamos con el tema audio-vídeo, salvo que seamos expertos de la materia (si así fuera.. ¿que haces perdiendo el tiempo con esto?), estaremos haciendo el canelo;y especialmente en el caso de gráficos pues, aun haciendo uso extensivo de código en ensamblador, estaremos desaprovechando (salvo que también nos metamos en esos berenjenales) la potencia de las tarjetas de vídeo actuales. Claro que hay mucho fan del lema "si quieres que las cosas salgan bien, hazlas tú mismo" (yo entre ellos). Esto generalmente lleva a peores resultados y la pérdida de tiempo que supone reinventar la rueda cada dos por tres, pero.. ¿y lo a gusto que se queda uno?
ENLACES Y MATERIAL VARIOPINTO Para cualquier duda o comentario (que no sea sobre mis soberbias cualidades como
diseñador de páginas web) mi dirección es
uc18492@alumnos.unican.es Ernesto Pérez Serna