Ú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?

  1. Un ordenador (si estás leyendo esto no creo que sea un problema) compatible con la familia 80x86 (¿alguien usa mac?)
  2. En principio MS-DOS o Windows 95,98,Me.. más allá no sé, que no he probado. Mientras te ejecute programas DOS, perfecto. Por supuesto gran parte de lo que se aprenda será válido para cualquier otro sistema operativo (de hecho la programación en ensamblador es la misma), pero nos apoyaremos en el DOS en el tutorial. Luego introduciremos los sistemas 32 bits.
  3. Un ensamblador. Lo que aquí cuente está pensado para la sintaxis del TASM (Turbo Assembler) No he probado más, me gusta, es bonito y es la costumbre. ¿Que de dónde pillas el TASM? Pues es un programa comercial así que no tengo ni idea 0:) MASM (el de Microsoft) usa sintaxis prácticamente idéntica. Quizá el ensamblador para Linux más parecido sea NASM (que por cierto también está disponible para Windows, y soporta hasta instrucciones MMX), así que los interesados no tendrán demasiada dificultad en ir enredando con lo que se muestre aquí (si bien es cierto que el tema de las directivas y los ejemplos habrán de tomarse con pinzas) si se leen la documentación como es debido..

    Para ensamblar solamente hay que escribir "tasm32 archivo.asm", con el modificador /l (ele) si queremos que genere un archivo ".lst". El enlazado se realiza mediante "tlink archivo.obj " o "tlink32 archivo.obj " dependiendo de si vamos a generar un ejecutable de 16 o 32 bits.

    Por si alguien está pensando en para qué leches introducir la programación en MSDOS en estos tiempos tan modernos diré que a) es la que más controlo, b) es más simple en algunos aspectos, c) tampoco es para tanto, si algo no gusta se lo puede uno saltar y d), la más importante de todas, porque lo digo yo. Si a alguien le da por mirar cómo se programa el sector de arranque (caso muy hipotético, pero bueno, hay gente pa' tó) verá que el ordenador arranca en modo real (16 bits al canto), para luego efectivamente saltar al modo protegido (32 bits). Además, no me gusta empezar a contar las películas por el final.

¿Listo todo? Abróchense los cinturones, damas y caballeros, que despegamos.

Regresar al índice

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.

Regresar al índice

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.

    AX es a menudo llamado acumulador, más por motivos históricos que por otra cosa.
    BX se puede usar como registro base en algunos modos de direccionamiento, es decir, para apuntar a posiciones de memoria con él.
    CX es usado por algunas instrucciones como contador (en ciclos, rotaciones..)
    DX o registro de datos; a veces se usa junto con AX en esas instrucciones especiales mencionadas.

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:

    AX = AH | AL          BX = BH | BL
    CX = CH | CL          DX = DH | DL

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

Regresar al índice

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:

  1. No se puede mover de memoria a memoria
  2. No se pueden cargar registros de segmento en direccionamiento inmediato
  3. No se puede mover a CS

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.

Regresar al índice

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:

  • DB Define Byte, 1 byte
  • DW Define Word, 2 bytes
  • DD Define Double Word (4 bytes)
  • DQ Define Quad Word (8 bytes)
  • DT Define Ten Bytes (10 bytes; aunque parezca muy raro, cuando veas el coprocesador no podrás vivir sin ella)

    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.

    Regresar al índice

    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

  • MOVE, MOVEQ, MOVEM
  • 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.

  • CLR
  • 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í.

  • EXG
  • 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.

  • SWAP
  • 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, ADDQ, ADDI, ADDX
  • 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, SUBQ, SUBI, SUBX
  • 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.

  • MULU, MULS
  • 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.

  • DIVU, DIVS
  • 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

  • EXT
  • 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, NEGX
  • 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,OR,EOR,NOT
  • AND destino,origen
    OR destino,origen
    XOR destino,origen
    NOT destino

    Estas yo creo que están bien claritas :)

    -DESPLAZAMIENTOS Y ROTACIONES

  • ASL,ASR
  • 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}

  • LSL,LSR
  • 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,ROR
  • 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}

  • ROXL,ROXR
  • 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

  • TST
  • No existe. Hay que sustituirla por un CMP.

  • CMP,CMPI,CMPA,CMPM
  • 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

  • BRA
  • 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.

  • Bcc
  • 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).

  • DBcc
  • 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

  • JSR,BSR
  • 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.

  • RTS,RTR
  • 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.

  • LINK,UNLK
  • 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,PEA
  • 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

  • BTST,BSET,BCLR,BCHG,TAS
  • 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

  • STOP
  • HLT (HaLT, parada)

    Detiene el microprocesador hasta la llegada de una interrupción o de una señal de reset.

  • NOP
  • 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

  • TRAP,TRAPV
  • 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).

    Regresar al índice

    CAPÍTULO VI: JUEGO DE INSTRUCCIONES DEL 8087 (y del 387)
    El amigo de los niños

    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:

    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

    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.

  • FWAIT (o WAIT, que es lo mismo)
  • 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:

  • FLD origen (LoaD)
  • 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)

  • FILD origen (Integer LoaD)
  • Empuja el contenido de memoria a la pila. Tiene que ser un número entero de cualquiera de los tamaños válidos.

  • FBLD origen (BCD LoaD)
  • Empuja el contenido de memoria a la pila. Tiene que ser un número BCD empaquetado de 10 bytes

  • FST destino (STore)
  • 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)

  • FIST destino (Integer STore)
  • 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.

  • FSTP destino (STore and Pop)
  • Extrae ST y lo guarda en memoria. El destino ha de ser de 4,8 o 10 bytes, almacenándolo como un real.

  • FISTP destino (Integer STore and Pop)
  • Extrae ST y lo guarda en memoria. El destino ha de ser de 2,4 u 8 bytes, almacenando el resultado como un entero.

  • FBSTP destino (BCD STore and Pop)
  • Extrae ST y lo guarda en memoria. El destino ha de ser de 10 bytes, convirtiendo el resultado en BCD empaquetado.

  • FXCH ST(n) (eXCHange)
  • Intercambia ST y ST(n). Si se omite el operando se sobreentiende ST(1)

  • FFREE ST(n)
  • 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

  • FINCSTP (INCrement STack Pointer, incrementar puntero de pila)
  • 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)

  • FDECSTP (DECrement STack Pointer, decrementar puntero de pila)
  • Decrementa el puntero de pila sin modificar el contenido de los registros.

    Instrucciones de carga de constantes:

  • FLDZ (LoaD Zero)
  • FLD1 (LoaD 1)
  • FLDPI (LoaD PI)
  • FLDL2E (LoaD Log2(e))
  • FLDL2T (LoaD Log2(10))
  • FLDLG2 (LoaD Log10(2))
  • FLDLN2 (LoaD LN(2))
  • 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)

  • FADD (ADDition)
  • Suma ST+ST(1), extrae ambos operandos de la pila y almacena en ella el resultado.
    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

  • FIADD [memoria] (Integer ADDition)
  • ST = ST + [memoria]
    Memoria ha de ser un entero (complemento a dos)

  • FADDP ST(n),ST (ADDition&Pop)
  • ST(n) = ST(n)+ST, retirando el valor de ST de la pila.

  • FSUB (SUBstraction)
  • Resta ST(1)-ST, extrae ambos operandos de la pila y almacena en ella el resultado.
    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)

  • FISUB [memoria] (Integer SUBstraction)
  • ST = ST - [memoria]
    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)

  • FSUBP ST(n),ST (SUBstraction and Pop)
  • ST(n) = ST(n)-ST, retirando el valor de ST de la pila.

  • FSUBR (SUBstraction Reverse)
  • Resta ST-ST(1), extrae ambos operandos de la pila y almacena en ella el resultado.

  • FSUBR,FISUBR,FSUBR,FSUBRP
  • 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)

  • FMUL (MULtiplication)
  • Multiplica ST(1) por ST, extrae ambos operandos de la pila y almacena en ella el resultado.
    FMUL [memoria]; ST = ST*[memoria]
    FMUL ST(n), ST; ST(n) = ST(n)*ST
    FMUL ST, ST(n); ST = ST*ST(n)

    FIMUL (Integer MULtiplication

    ST = ST*[memoria] Memoria ha de ser un entero.

  • FMULP ST(n),ST (MULtiplication and Pop)
  • ST(n)=ST(n)*ST, retirando ST de la pila.

  • FDIV (DIVision)
  • Divide ST(1) entre ST, extrae ambos operandos de la pila y almacena en ella el resultado.
    FDIV [memoria]; ST = ST / [memoria]
    FDIV ST(n), ST; ST(n) = ST(n)/ST
    FDIV ST, ST(n); ST = ST / ST(n)

  • FIDIV, FDIVP, FDIVR, FIDIVR, FDIVR, FDIVRP...
  • Cuando es I como los Integer de arriba, P Pop, R Reverse, etc etc

  • FABS (ABSolute)
  • Pone el signo de ST a positivo (calcula su valor absoluto)

  • FCHS (CHange Sign)
  • Cambia el signo de ST

  • FRNDINT (RouND to INTeger)
  • Redondea ST a un entero

  • FSQRT (SQuare RooT)
  • Reemplaza ST con su raíz cuadrada

  • FSCALE (SCALE)
  • 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.

  • FPREM (Partial REMinder)
  • 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.

  • FXTRACT (eXTRACT)
  • 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

  • FCOS
  • Sustituye ST por su coseno

  • FSIN
  • Sustituye ST por su seno

  • FSINCOS
  • 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)

  • FPTAN
  • Sustituye ST por su tangente

  • FPATAN
  • Calcula ATAN(Y/X), donde Y=ST(1) y X=ST(0). Ambos operandos se destruyen.

    Instrucciones logarítmicas/exponenciales

  • FYL2X (Y*Log2(X))
  • Calcula ST(1)*log2(ST) y lo almacena en la pila, destruyendo ST y ST(1)

  • FYL2XP1 (Y*Log2(X) Plus 1)
  • Calcula ST(1)*log2(ST+1)

  • F2XM1 (2^X Minus 1)
  • ST = 2^ST -1. El ST previo debe estar comprendido entre -1 y 1

  • FSCALE (SCALE, escalar)
  • 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).

    Regresar al índice

    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.

    Regresar al índice

    CAPÍTULO VIII: ENTRADA/SALIDA
    Servicios del MSDOS y la BIOS. Los puertos E/S.

    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.

  • Enviar un carácter a la salida estándar
  • AH=6h
    DL=código ASCII del carácter (excepto 0FFh)

  • Enviar una cadena a la salida estándar
  • AH=9h
    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.

  • Leer carácter de la entrada estándar, con eco a la salida
  • AH=1h
    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.

  • Leer carácter de la entrada estándar, sin eco a la salida
  • AH=7h
    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.

  • Leer carácter de la entrada estándar sin esperar pulsación
  • AH=6h
    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.

  • Leer cadena de la entrada estándar
  • AH=0Ah
    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.

    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 que quiera profundizar en los gráficos bajo MSDOS puede consultar la guía de interrupciones proporcionada en los enlaces, pues todas las funciones de la BIOS de este tipo se agrupan bajo la misma interrupción (10h). Otros conjuntos de funciones de la BIOS de utilidad incluyen las de acceso al teclado (INT 16h, pudiendo leer, entre otras cosas, el estado de los leds o la pulsación de las teclas de "shift"), puerto serie (INT 14h), la impresora (INT 17h), o los discos (INT 13h, nada recomendable jugar con ella salvo que se sepa perfectamente qué se hace; las escrituras erráticas a bajo nivel en el disco duro pueden tener consecuencias nefastas). En la referencia dada se encuentran casi todas las funciones del MSDOS (incluyendo algunas no documentadas), gestores de memoria como el QEMM386 o drivers de vídeo SVGA VESA. No requieren más explicación que la que acompaña a cada servicio en la documentación, pues todo se limita a llamar a una interrupción.

    Se incluyen aquí únicamente unas pocas funciones de salida para complementar las anteriores

  • Cambiar la posición del cursor
  • 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)

  • Obtener la posición del cursor
  • AH=3
    BH=página de la que obtener la posición
    La posición se devuelve en DX; DH=fila, DL=columna

  • Seleccionar página activa
  • AH=5
    AL=página activa

  • Borrar región de la pantalla
  • 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.

  • OUT destino, origen
  • 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.

  • IN destino, origen
  • 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.

    Regresar al índice

    CAPÍTULO IX: EL MODELO DE MEMORIA EN MSDOS
    Brevísimas anotaciones sobre un sistema obsoleto.

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

    Regresar al índice

    CAPÍTULO X: SISTEMAS DE 32 BITS
    Modo protegido. Interacción C&Ensamblador.

    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

    	
    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.

    Regresar al índice

    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

  • MOVD (MOVe Dword)
    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.
  • MOVQ (MOVe Qword)
    Copia una palabra cuádruple (64 bits) de origen en destino. Cualquier operando puede ser memoria o un registro MMX, o incluso ambos MMX. No se admiten los dos operandos en memoria.

  • 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

  • PADDB, PADDW, PADDD (Packed ADD Byte/Word/Dword)
    Suman los paquetes con signo (bytes, words, o dwords) de destino y origen, almacenando el resultado en destino. Si el resultado de un paquete queda fuera del rango se trunca a la parte más baja (es decir, se ignoran los bits de carry de cada suma individual)
  • PADDSB, PADDSW (Packed ADD with Saturation Byte/Word)
    Suman los paquetes con signo (bytes o words) de origen y destino, almacenando el resultado en destino. Si el resultado de un paquete queda fuera del rango de representación, se limita al máximo o mínimo según corresponda: aplica saturación
  • PADDUSB, PADDUSW (Packed ADD Unsigned with Saturation Byte/Word)
    Suma los paquetes sin signo de origen y destino, almacenando el resultado en destino. Aplica saturación.

  • PSUBB, PSUBW, PSUBD (Packed SUBstract Byte/Word/Dword)
    Resta a los paquetes de destino (bytes, words o dwords) los de origen.

  • PSUBSB, PSUBSW (Packed SUBstract with Saturation Byte/Word)
    Resta a los paquets de destino con signo (bytes o words) los de origen. Aplica saturación.
  • PSUBUSB, PSUBUSW (Packed SUBstract Unsigned with Saturation Byte/Word)
    Resta a los paquets de destino con signo (bytes o words) los de origen. Aplica saturación.
  • PMULHW, PMULLW (Packed MULtiply High/Low Word)
    Multiplica las cuatro palabras del operando origen con las cuatro del operando destino, resultando en cuatro dwords. La parte alta o baja respectivamente de cada dword es almacenada en los paquetes correspondientes del destino. Las operaciones se realizan sobre números con signo.
  • PMADDWD (Packed Multiply and ADD)
    Multiplica las cuatro palabras del operando origen con las cuatro del operando destino, resultando en cuatro dwords. Las dos dwords superiores son sumadas entre sí y almacenadas en la parte alta del destino. Lo mismo con las dwords inferiores en la parte inferior del destino.
  • - INSTRUCCIONES DE COMPARACIÓN

  • PCMPEQB, PCMPEQW, PCMPEQD (Packed CoMPare for EQual Byte/Word/Dword)
    PCMPGTB, PCMPGTW, PCMPGTD (Packed CoMPare for Greater Than Byte/Word/Dword)
    Realiza la comparación de igualdad o de "mayor que" entre las palabras de origen y de destino, almacenando en destino el resultado de dicha comparación (verdadero todo a 1, falso todo a 0).
  • - INSTRUCCIONES LÓGICAS

  • PAND, PANDN, POR, PXOR (Packed AND/AND Not/OR/XOR)
    Realizan las operaciones lógicas correspondientes bit a bit entre operando y destino, almacenando el resultado en destino. PANDN realiza la operación NOT sobre los bits de destino, y a continuación efectúa AND entre operando y destino, almacenando el resultado en destino. PANDN de un registro consigo mismo equivale a NOT, pues A AND A = A.
  • - INSTRUCCIONES DE DESPLAZAMIENTO

  • PSLLW,PSLLD (Packed Shift Left Logical Word/Dword)
    PSRLW,PSRLD (Packed Shift Right Logical Word/Dword)
    Efectuan desplazamientos lógicos hacia la izquierda o derecha (Left/Right) sobre las palabras o palabras dobles empaquetadas (Word/Dword); desplazan los bits y rellenan con ceros.
  • PSRAW,PSRAD (Packed Shift Right Arithmetic Word/Dword)
    Efectuan desplazamientos aritméticos hacia la derecha sobre las palabras o palabras dobles empaquetadas (Word/Dword); equivalen a dividir entre 2 números con signo, pues en el desplazamiento rellenan con el bit más significativo.
  • 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.

    Máscara del personaje

    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?

    Regresar al índice

    ENLACES Y MATERIAL VARIOPINTO

  • Universo Digital. Aunque está un pelín desfasado en cuanto al sistema operativo (MSDOS) tiene abundante información sobre programación a muy bajo nivel; da detalles sobre el funcionamiento y acceso a unidades de disco, puertos serie, timers.. a nivel hardware. Para el que quiera libros de esos de papel, que vaya a la bibliografía de esa misma página. En general muy recomendable.
  • The Art of Assembly . Un conjunto de manuales extremadamente completos de programación en ensamblador, explicando desde estructuración de datos hasta acceso al hardware del sistema. Textos en PDF y online.
  • Ralf Brown. Uno de tantos sitios desde donde bajarse la documentación de este caballero. Para muchos, la biblia.
  • Web de Darío Alpern. Página personal de un tío al que no conoce ni su madre, que aunque no trae demasiado material de programación, contiene una minienciclopedia de los microprocesadores Intel que detalla gran parte de su arquitectura.
  • DDJ Microprocessor Center. Este enlace contiene muchísima información sobre microprocesadores, entre ella la documentación oficial de Intel, así como numerosos bugs y características no documentadas. La leche.
  • How to optimize for the Pentium family of microprocessors. Consejos para programación en microprocesadores Pentium.
  • linuxassembly.org. Ensamblador para linuxeros.
  • Web del canal #asm del irc hispano. Cuenta con algunos fuentes y descargas de los ensambladores más usados.
  • Compilador C++ gratuito de Borland.
  • Write your own operative system. Descripción básica del arranque del sistema (carga del código del sector de arranque), formatos de ejecutables, sistemas de archivos.. Quizá de título tan optimista como el de mi web.
  • Beyond Logic. Recursos sobre acceso a puertos serie, usb, paralelo.. para diversas plataformas, con bastantes tutoriales.
  • www.hardwarebook.net. Información sobre distintos cables, pinouts de conectores y circuitillos varios.
  • Tutorial del Modo Protegido. Como entrar y salir del modo protegido en MSDOS. No tan sencillo como suena.
  • Ejemplos de acceso al modo protegido desde MSDOS.
  • 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