CAPÍTULO VI: JUEGO DE INSTRUCCIONES DEL 8086
¡Mamá, ya sé sumar!

A continuación voy a describir brevemente todas las instrucciones que admite un 8086; entre paréntesis señalaré, si toca, los flags que altera cada una. Recordemos que el registro de flags contiene alguos indicadores que se ponen a "0" o a "1" según el resultado de la última operación realizada (se obtiene un número negativo, ha habido carry, overflow..) Con las instrucciones se mostrará qué flags son modificados por este motivo, pero no se garantiza que el resto de flags "informativos" no se verán alterados por la ejecución de la instrucción (o sea, el valor de ciertos flags puede quedar indeterminado tras la operación). Para detalles escabrosos sobre el funcionamiento de ciertas instrucciones, es mejor consultar una tabla de referencia en condiciones, como las que tiene Intel por ahí en pdf en su página web, o la que viene con el manual de NASM.

Aunque el título dice "del 8086", entremezclo algunas peculiaridades de los procesadores superiores; en la mayoría de los casos quedará claro si sirven o no en un 8086 (si usan EAX, por ejemplo, es evidente que no). Una diferencia fundamental del 8086 con sus descendientes es que algunas de estas mismas instrucciones pueden recibir, además de los operandos habituales, otros adicionales propios de los nuevos procesadores. Me explico. Uno en un 386 puede hacer ADD AX,BX (AX=AX+BX) como un 8086 cualquiera, pero además, por contar con registros de 32 bits, podrá hacer cosas del estilo ADD EAX,EBX. En ambos casos se tratará de la misma instrucción (ADD, sumar), sólo que si el procesador cuenta con más registros, o registros extendidos, o simplemente capacidad de admitir nuevos operandos, podremos usarlos. Para esta instrucción en particular la sintaxis se describirá como ADD destino, origen, pero por destino y origen no será necesariamente válido cualquier modo de direccionamiento.. Dependerá de la instrucción, y en cierta medida del procesador que se use. En la mayoría de los casos imperarán las reglas generales "no es válido operar de memoria a memoria" y "no es válido operar con registros de segmento". Hay además una cierta lógica en estas restricciones (el operando destino generalmente es un registro de propósito general), e instrucciones similares entre sí aceptarán casi con toda seguridad los mismos operandos. Se harán algunas observaciones sobre los operandos válidos para cada instrucción, pero de nuevo para cualquier duda lo mejor será echar mano de una tabla "oficial" (que es lo que hacemos todos, no te creas que alguien se lo aprende de memoria).

Los procesadores posteriores al 8086 incluyen no sólo mejoras en las instrucciones, sino instrucciones adicionales. De éstas sólo se indicarán, un tanto desperdigadas por ahí, las que puedan ser utilidad. Las demás no digo que sean inútiles, sino que en la mayoría de los casos no nos estarán permitidas. Hay un conjunto de instrucciones exclusivas del modo protegido que sólo podrán ser usadas por el sistema operativo, y se dice que son instrucciones privilegiadas; un programa de usuario que intente ejecutar una operación de este tipo será detenido, generalmente con un mensaje que indique un error de protección general. Casi todas las instrucciones privilegiadas corresponden a operaciones que pueden afectar al funcionamiento del resto de procesos del sistema (en modo protegido, por lo general, tendremos varios programas funcionando al tiempo, aunque en un momento dado el usuario intente cargar "el suyo"). Un ejemplo es INVD, que invalida la información de la memoria cache interna. Si no fuera ésta una instrucción privilegiada, cualquiera podría, en cualquier momento, desbaratar la cache con un programa malintencionado (recordemos que puede suceder que haya varios usuarios trabajando al tiempo en un cierto ordenador). Las instrucciones IN/OUT son usadas ya por el 8086 (de hecho son fundamentales), pero no se incluyen en este capítulo porque sin ser propias del modo protegido, cuando el micro funciona en este modo,se consideran instrucciones privilegiadas. A ellas me referiré en otro capítulo. Finalmente, los conjuntos especiales de instrucciones (coprocesador matemático, MMX, SSE..) se comentarán en capítulos aparte.

La discusión más delicada tiene lugar cuando diferenciamos un procesador funcionando "en 16 bits" (modo real, que se comporta como un 8086 aunque no lo sea), y "en 32 bits" (modo protegido, cualquier 386+ en Linux, Windows, etc), porque a menudo crea confusión. Cuando estamos ejecutando un programa para MSDOS (aunque sea desde Windows, ya que estará emulando un entorno de MSDOS), nuestro procesador dispondrá de, tal vez, más registros, pero como ya hemos visto seguirá obteniendo las direcciones como segmento*16+offset (aunque pueda realizar operaciones con los registros extra, o incluso direccionar la memoria con ellos en esta circunstancia). Existen instrucciones que ya no según el procesador sino según el modo de funcionamiento se comportan de un modo u otro; ése es el caso de LOOP, que en modo real altera CX y en modo protegido ECX (normalmente prefiero decir "32 bits" a "modo protegido", porque existen condiciones excepcionales bajo las cuales se puede ejecutar código de 16 bits en modo protegido; no entraré de momento en esta discusión). En modo 32 bits, además, los offsets son de este tamaño, lo que significa que los punteros de pila, instrucción, etc, serán de tipo dword. Importantísimo tenerlo presente al pasar argumentos a subrutinas a través de la pila (daré mucho la brasa con esto a lo largo del tutorial).

- INSTRUCCIONES DE MOVIMIENTO DE DATOS

  • MOV destino, origen (MOVe, mover)
  • Bueno, qué voy a contar de ésta que no haya dicho ya.. Vale, sí, un pequeño detalle. Cuando se carga SS con MOV, el micro inhibe las interrupciones hasta después de ejecutar la siguiente instrucción. Aún no he dicho lo que son las interrupciones ni qué tiene de especial la pila así que tardaremos en comprobar qué puede tener de útil este detalle, y la verdad es que normalmente nos dará lo mismo, pero bueno, por si las moscas.

  • XCHG destino, origen (eXCHanGe, intercambiar)

    Intercambia destino con origen; no se puede usar con registros de segmento. Esta instrucción encuentra su utilidad en aquellas instrucciones que tienen asociados registros específicos.

    - OPERACIONES ARITMETICAS

  • ADD destino, origen (ADDition, sumar) {O,S,Z,A,P,C}
  • Suma origen y destino, guardando el resultado en destino. Si al sumar los dos últimos bits "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,C}
  • Suma origen, destino y el bit de carry, guardando el resultado en destino. Sirve entre otras cosas para sumar números de más de 16 bits arrastrando el bit de carry de una suma a otra. Si quisiéramos sumar dos números enteros de 64 bits almacenados en EAX-EBX y ECX-EDX, podríamos sumarlos con ADD EBX,EDX primero y ADC EAX,ECX después (para sumar las partes altas de 32 bits con "la que nos llevábamos" de las partes bajas). Sumar con ADC puede generar a su vez carry, con lo que teóricamente podríamos sumar números enteros de cualquier tamaño, propagando el carry de una suma a otra.

    Podemos poner a 1 el flag de 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 operando de tamaño "byte" o superior, tanto un registro como una posición de memoria; en este último caso hay que especificar tamaño con WORD, DWORD etc, tal y como explicamos en el capítulo III con los modos de direccionamiento. Esta instrucción no modifica el bit de carry; si quieres detectar cuándo "te pasas" al incrementar un contador, usa el flag "Zero" o el de signo.

  • SUB destino, origen (SUBstract, resta) {O,S,Z,A,P,C}
  • Resta a destino lo que haya en origen.

  • SBB destino, origen (SuBstract with Borrow, restar con llevada) {O,S,Z,A,P,C}
  • Resta a destino lo que haya en origen. Si el flag C=1 resta uno más. Análoga a ADC.

  • DEC destino (DECrement, decrementar) {O,S,Z,A,P}
  • Igual que INC, pero que resta 1 en vez de sumarlo.

  • IMUL origen (Integer MULtiplication, multiplicación entera con signo) {O,C}
  • Multiplica origen, entero con signo (en complemento a dos), 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 valores inmediatos). Esto se aplica para IMUL, MUL, IDIV y DIV.

    IMUL tiene otras dos formas más para procesadores posteriores al 8086. La primera es IMUL destino, origen donde el destino es un registro de propósito general y el origen un valor inmediato, otro registro o una posición de memoria. La segunda forma tiene el aspecto IMUL destino, origen1, origen2. Destino es nuevamente un registro de propósito general, origen1 un registro o posición de memoria, y origen2 un valor inmediato. Lo que hace es almacenar el producto de los dos operandos origen en destino. En cualquiera de las dos formas, si el resultado del producto no cabe en el destino, queda truncado. Nos toca a nosotros comprobar, como siempre que sea necesario, los bits de overflow y carry (si se usa un único operando no hace falta, porque el destino siempre cabe).

  • MUL origen (MULtiplication, multiplicación entera sin signo) {O,C}
  • Como IMUL, salvo que multiplica enteros sin signo. Sólo admite la forma con un único operando.

  • IDIV origen (Integer DIVide, división entera con signo)
  • Divide números con signo. Calcula el cociente y el resto de dividir AX entre el operando (tamaño 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.

  • DIV origen (DIVide, división entera sin signo)

    Igual que IDIV, sólo que para números sin signo.

    - INSTRUCCIONES DE SOPORTE ARITMÉTICO

    La mayor parte de estas instrucciones sirven para extender el signo. Ya hemos visto que puede suceder que tengamos que operar con un número repartido en dos registros como DX-AX. Imagina que quieres cargar un entero con signo de 16 bits en estos dos registros: ¿qué haces con los otros 16 más altos? Si el entero es positivo basta con poner a 0 DX, pero si es negativo tendrás que darle el valor 0FFFFh:

    1d = 0000 0001h
    -1d = FFFF FFFFh

    Como todo lo que sea abreviar comparaciones y saltos es siempre bienvenido, a alguien se le ocurrió hacer instrucciones especialmente para esta tarea. Puedes extender el signo dentro de un registro o hacia otro, trabajando fundamentalmente sobre el acumulador (por lo que no son demasiado flexibles). Las MOVSX y MOVZX -del 386- darán más juego.

  • CWD (Convert Word to Doubleword, convertir palabra a palabra doble)
  • Extiende el signo de AX a DX, resultando el número DX-AX

  • CQD (Convert Doubleword to Quad-word, convertir palabra doble a palabra cuádruple)

    Extiende el signo de EAX a EDX, resultando el número EDX-EAX

  • CBW (Convert Byte to Word, convertir byte a palabra)
  • Extiende el signo de AL a AH, resultando el número AX

  • CWDE (Convert Word to Doubleword Extended, convertir palabra a palabra doble extendida)
  • Extiende el signo de AX a EAX, resultando el número EAX

  • MOVSX destino,origen (Move with Sign Extension, mover con extensión de signo)
  • Mueve origen a destino, extendiendo el signo. Si el destino es de 16 bits, origen ha de ser de 8. Si destino es de 32 bits, origen puede ser de 8 o de 16. Sólo acepta mover a un registro (de datos).

  • MOVZX destino,origen (Move with Zero Extension, mover con extensión de ceros)
  • Exactamente como MOVZX, sólo que en vez de extender el signo rellena de 0s.

  • NEG destino (NEGate, negar){O,S,Z,A,P,C}
  • Cambia de signo el número en complemento a dos del destino. Equivale a hacer NOT y luego INC.

    -INSTRUCCIONES LÓGICAS

  • AND destino,origen
  • OR destino,origen
  • XOR destino,origen
  • NOT destino
  • Creo que si sabes lo que significan estas operaciones lógicas (si no revisa el capítulo 0), estarán bien claritas :)

    -DESPLAZAMIENTOS Y ROTACIONES

  • SAL destino,origen (Shift Arithmetic Left, desplazamiento aritmético a la izquierda) {O,S,Z,P,C}
  • SAR destino,origen (Shift Arithmetic Right, desplazamiento aritmético a la derecha) {O,S,Z,P,C}
  • SHL destino,origen (SHift logic Left, desplazamiento lógico a la izquierda) {O,S,Z,P,C}
  • SHR destino,origen (SHift logic Right, desplazamiento lógico a la derecha) {O,S,Z,P,C}

  • ROL destino,origen (ROtate Left, rotación a la izquierda) {O,C}
  • ROR destino,origen (ROtate Right, rotación a la derecha) {O,C}
  • RCL destino,origen (Rotate with Carry Left, rotación a la izquierda con carry) {O,C}
  • RCR destino,origen (Rotate with Carry Right, rotación a la derecha con carry) {O,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.

    Es bueno recordar que desplazar un número a la izquierda un bit equivale a multiplicar por dos, y desplazarlo a la derecha dividir entre dos. Cuando queramos realizar alguna de estas operaciones en un factor potencia de dos siempre será mucho más rápido desplazar un registro (o varios, propagando el carry) que realizar una multiplicación/división. Hilando un poco más fino, si tenemos que multiplicar un número por algo que no sea una potencia de dos pero "se le parezca mucho", el método puede seguir compensando. Para multiplicar por 20 podemos hacer una copia del registro, desplazarlo por un lado 4 bits (para multiplicar por 16) y por otro 2 bits (para multiplicar por 4), y sumar ambos resultados parciales. Si nuestro procesador está un poco pasado de moda (386-486) puede que la mejora sea aún significativa.

    -INSTRUCCIONES DE COMPARACIÓN

  • 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 en lugar de SUB.

    -INSTRUCCIONES DE SALTO

  • JMP dirección (JuMP, saltar)
  • 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. Un salto cercano tiene lugar dentro del mismo segmento (llamado también salto intrasegmento por este motivo), cargando IP (EIP en modo protegido) con el nuevo offset. Los saltos lejanos cambiar el valor tanto de IP(EIP) como de CS.

    El tipo de salto se puede indicar con la dirección mediante los prefijos near o far en cada caso. Existe un tercer tipo de salto denominado corto indicado por el prefijo short, cuya dirección viene codificada en un byte. Este último salto es en realidad una forma abreviada de expresar un salto cercano, donde en lugar de indicar la dirección a la que se salta, se especifica la distancia desde la posición actual como un número de 8 bits en complemento a dos.

  • Jcc dirección
  • "cc" representa la condición. Si la condición es verdadera, salta. En caso contrario, continúa la ejecución. Las distintas condiciones se presentan en la tabla inferior. 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 (lo que significa que la distancia se codificaba en un byte), obligando 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, con lo que prácticamente no tenemos limitación alguna. Si queremos saltar cuando el bit de carry sea 1, podemos hacerlo con algo como JC etiqueta y olvidarnos de todo lo demás.

    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)

    CMP equivalía a una resta que no modificaba los operandos; si queremos saltar si A<B, podemos hacer CMP A,B. Si A<B al hacer la resta me llevaré una, por lo que el bit de carry se pondrá a 1. Vemos que JC es equivalente a JB. Con similares deducciones se pueden obtener algunos saltos más; otros, sin embargo, dependen del resultado de dos flags al tiempo ("menor o igual" saltará con CF=1 y ZF=1), por lo que sus mnemónicos corresponderán a instrucciones nuevas.

    Para poner las cosas aún más fáciles, 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. Una N delante indica la condición contraria. Si uno no quiere líos, basta que recuerde que Below/Above son para números sin signo y Less/Greater para con signo.

    Una instrucción de salto muy útil en combinación con las siguientes (LOOP y variaciones) es JCXZ (Jump if CX is Zero, salta si CX es cero), o JECXZ si estamos operando en 32 bits.

    Para hacer ciclos (algo así como for i=1 to 10) la familia del 8086 cuenta con LOOP y la pareja LOOPE/LOOPZ (mnemónicos de lo mismo)

  • 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 respecto a IP para el 8086, o +(2^31-1)/(-2^31) para el 80386, igual que las instrucciones Jcc. Al programar no usaremos desplazamientos ni direcciones absolutas, sino etiquetas que se encargará de sustituir el ensamblador, por lo que no debe (en principio) pasar de ser una anécdota.

    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 modo de funcionamiento es de 32 bits LOOP, LOOPE y LOOPZ operan sobre ECX. 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 (ECX=0) estos ciclos se ejecutan en principio 2^16 (2^32) veces, podemos evitarlo mediante el uso de JCXZ (JECXZ) justo antes del ciclo.

    -MANEJO DE LA PILA

    Mencionamos ya brevemente en el capítulo II en qué consiste la pila. Ahora veremos exactamente qué es, así como para qué y cómo se usa.

    Los programas se dividen básicamente en una zona de datos y otra de código, habitualmente cada cada una en su región de memoria asociada a un valor de registro de segmento (lo que se llama, simplemente, un segmento). Siempre hay al menos un tercer segmento llamado pila, de suma utilidad en los microprocesadores. En él se almacenan valores temporales como las variables locales de las funciones, o las direcciones de retorno de éstas. Una función no es más que una subrutina, o un fragmento de código al que se le llama generalmente varias veces desde el programa principal, o desde una función jerárquicamente superior. Cuando se llama a una función se hace un mero salto al punto donde empieza ese código. Sin embargo esa subrutina puede ser llamada desde distintos puntos del programa principal, por lo que hay que almacenar en algún sitio la dirección desde donde se hace la llamada, cada vez que esa llamada tiene lugar, para que al finalizar la ejecución de la función se retome el programa donde se dejó. Esta dirección puede almacenarse en un sitio fijo (como hacen algunos microcontroladores), pero eso tiene el inconveniente de que si esa función a su vez llama a otra función (¡o a sí misma!) podemos sobreescribir la dirección de retorno anterior, y al regresar de la segunda llamada, no podríamos volver desde la primera. Además, es deseable que la función guarde los valores de todos los registros que vaya a usar en algún sitio, para que el que la llame no tenga que preocuparse de ello (pues si sabe que los registros van a ser modificados, pero no sabe cuáles, los guardará todos por si acaso). Todas estas cosas, y algunas más, se hacen con la pila.

    El segmento de pila está indicado por SS, y el desplazamiento dentro del segmento, por SP (o ESP si estamos en 32 bits; todo lo que se diga de SP de aquí en adelante será aplicable a ESP, salvo, como se suele decir, error u omisión).

    Cuando arranca el programa, SP apunta al final del segmento de pila. En el caso de los programas de tipo EXE en MSDOS, por ejemplo, el tamaño que se reserva a la pila viene indicado en el ejecutable (que solicita al sistema operativo ese hueco; si no hay memoria suficiente para el código, los datos y la pila, devuelve mensaje de memoria insuficiente y no lo ejecuta). En otros, puede que el tamaño lo asigne el sistema operativo según su conveniencia. El caso es que la manera de meter algo en la pila es decrementar SP para que apunte un poco más arriba y copiarlo a esa posición de memoria, SS:SP. Para sacarlo, copiamos lo que haya en SS:SP a nuestro destino, e incrementamos el puntero.

    Como con todo lo que se hace con frecuencia, hay dispuestas instrucciones propias 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. Lo que se incrementa/decrementa es siempre SP, claro, porque SS nos indica dónde está ubicado el segmento de pila.

    PUSHA y POPA (de PUSH All y POP All) almacenan en la pila o extraen de la pila respectivamente los registros básicos. Para PUSHA se sigue este orden (el inverso para POPA): 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.

    No creo que nadie vaya a programar exactamente en un 386, pero si alguien quisiera tener en cuenta a estos procesadores tendría que considerar que la mayoría tienen un defecto de diseño por el cual POPAD no restaura correctamente el valor de EAX. Para evitarlo basta colocar la instrucción NOP detrás de aquélla.

    PUSHF y POPF (PUSH Flags y POP Flags) empujan a la pila o recuperan de ella el registro de flags.

    -MANEJO DE SUBRUTINAS

  • CALL dirección (CALL, llamar)
  • Empuja a la pila la dirección de retorno (la de la siguiente instrucción) y salta a la dirección dada. Como en los saltos: una llamada cercana cambia el offset (IP/EIP), mientras que una lejana cambia offset y segmento (CS:IP/CS:EIP). Sólo empuja a la pila los registros que modifica con el salto (se habla de punteros cercanos o lejanos en cada caso). El tipo de salto se indica con un prefijo a la dirección, como con JMP: NEAR para cercano, FAR para lejano.

    En vez de la dirección admite también un operando en memoria con cualquier modo de direccionamiento indirecto, cargando entonces IP/EIP o CS:IP/CS:EIP con el puntero almacenado en esa posición de memoria. También se puede hacer un salto cercano al valor almacenado en un registro (de 16 bits o 32 bits según el caso). Para las direcciones de retorno que se guardan en la pila, o los punteros en memoria que se cargan, hay que recordar que en caso de ser un puntero lejano primero se almacena el offset y luego el segmento. A ver, para no liarla, vamos con algunos ejemplos de llamadas:

    call near etiqueta; salta a la dirección donde se encuentre la etiqueta
    call far [di]; salta a la dirección almacenada en la posición [ds:di] como segmento:offset
    call ebx; salta al offset ebx (se sobreentiende que es near)

    Si se omite far, se da por sentado que es una llamada cercana, pero por claridad recomiendo ponerlo siempre explícitamente.

  • 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 igual que con call. 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. De momento no trataremos interrupciones.

    La pila sirve no sólo para guardar los registros temporalmente o las direcciones de retorno, sino también las variables locales, que son imprescindibles para una programación cabal.

    La idea es que cuando vamos a llamar a una subrutina, echamos primero a la pila los argumentos que va a necesitar. Luego la subrutina, nada más arrancar salva en la pila los valores de los registros que considera necesarios, y "abre un hueco" encima de éstos, decrementando el puntero de pila en tantas unidades como necesite. La ventaja fundamental de esto es que cada llamada a la función "crea" su propio espacio para variables locales, de modo que una función puede llamarse a sí misma sin crear conflictos entre las variables de la función "llamadora" y "llamada" (caller y callee en algunos textos ingleses, aunque no estoy muy seguro de que el segundo término sea muy correcto). El único problema que tenemos ahora es que una vez abierto el hueco y determinadas las posiciones que ocuparán dentro de la pila nuestras variables, puede suceder que queramos echar más cosas a la pila, con lo que SP/ESP se desplazará arriba y abajo durante la ejecución de la subrutina; llevar la cuenta de en dónde se encuentra cada variable a lo largo de la función es algo poco práctico, y con lo que es fácil equivocarse. Sucede aquí que tenemos un registro que usa por defecto el segmento de pila: BP (nuevamente se ha de entender que lo que se aplica a SP,BP es extensible a ESP,EBP). Éste es el registro que emplearemos como puntero inamovible para señalar a argumentos y variables locales, para que no perdamos la pista de estos datos hagamos lo que hagamos con SP. Primero lo haremos todo paso a paso porque es importante entender el procedimiento:

    Vamos a crear una subrutina en modo real 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  [Sumando1]
    push word  [Sumando2]
    sub sp,2	;hueco para el resultado (queremos que nos lo devuelva en la pila)
    call SumaEstupida
    pop word  [Resultado]	;saca el resultado
    add sp,4	;elimina los sumandos de la pila
    ...
    
    y la subrutina sería:
    
    SumaEstupida:
    	;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
    

    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 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. Quedémonos úicamente con que si quisiéramos 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 u otro (llamada cercana o lejana), pues dependiendo de ello CALL introduce distinto número de elementos en la pila. La segunda es si estamos programando en 16 o en 32 bits, que nos determinará las dimensiones de lo que echemos a la pila. Cualquier consideración errónea de este tipo nos hará intentar acceder a los argumentos en posiciones equivocadas de la pila.

    -INSTRUCCIONES PARA OBTENER DIRECCIONES

  • LEA destino,origen (Load Effective Address, cargar dirección efectiva)
  • Carga la dirección efectiva del operando origen en destino. "LEA AX,[BX+DI+2]" calcularía la suma BX+DI+2 e introduciría el resultado en AX (y no el contenido de la dirección apuntada por BX+DI+2, pues eso sería un MOV). Como destino no se puede usar un registro de segmento. Si el destino es de 32 bits, el offset que se carga es de este tipo. En modo protegido sólo usaremos este último, pues offsets de 16 bits carecerán de sentido.

  • 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, porque nos desmadra la pila).

    -INSTRUCCIONES DE MANEJO DE BITS

  • BT/BTS/BTR/BTC registro,operando (Bit Test/Test&Set/Test&Reset/Test&Complement
  • Estas instrucciones lo primero que hacen es copiar el bit del registro indicado por el operando, sobre el bit de carry. El operando puede ser un registro o un valor inmediato de 8 bits. Una vez hecho esto no hace nada (BT), lo pone a 1 (BTS), lo pone a 0 (BTR) o lo complementa (BTC) según corresponda.

    -INSTRUCCIONES DE CADENA

    Hay 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(ESI) 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(EDI) 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(ECX). 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 (en 16 bits) 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. La "D" es de Double word, lo que significa que trabajan con palabras dobles, 32 bits. En lugar de AL/AX las operaciones tienen lugar con EAX; como todas las anteriores, en modo real usarán CX y SI/DI, y en modo protegido ECX y ESI/EDI. Todas ellas permiten prefijos REP.

    -INSTRUCCIONES BCD

  • AAA (ASCII Adjust AX After Addition) {A,C}
  • Convierte el número almacenado en AL a BCD desempaquetado. La idea es aplicarlo después de sumar BCDs no empaquetados. Esta instrucción mira los 4 bits más bajos de AL: si es mayor que 9 o AF (Flag Auxiliar) es igual a 1, suma 6 a AL, 1 a AH, hace AF=CF=1, y los cuatro bits más significativos de AL los deja a 0. ¿Lo qué?

    Vamos con el ejemplillo de marras. Tenemos en AX el BCD no empaquetado 47 (0407h) y en BX 14 (0104h). Queremos sumarlos, y que el resultado siga siendo un BCD no empaquetado, para obtener un resultado coherente. Partimos de que AF=0. Primero sumamos con ADD AX,BX porque no sabemos hacer otra cosa, y el resultado que nos deja es AX=050Bh. Uf, ni por el forro. ¿Qué hacemos? Aaa... Eso, la instrucción AAA. Como la parte baja de AL es mayor que 9, se da cuenta rápidamente de que ese número hay que ajustarlo (cosa que ya sabíamos, pero en fin). Aplica la receta: suma 6 a AL, y 1 a AH. AX entonces queda 0611h. Carga con ceros la parte alta de AH, AX=0601h. Vaya, esto ya está mejor. Así que con esto podemos hacer minisumas en BCD. Con los flags, movs y un poco de imaginación se pueden hacer sumas en BCD más grandes. Mi consejo es que, una vez entendido esto, te olvides de las instrucciones para BCD; el coprocesador matemático incluye instrucciones de conversión mucho menos enrevesadas (coges dos números enormes, los sumas, y guardas el resultado gordo directamente como BCD)

    Lo de "ASCII" para este tipo de instrucciones con BCD no empaquetados viene de que se puede obtener fácilmente el código ASCII de un número de éstos: sólo hay que sumarle el código del cero (48) a cada dígito. Si estuviera empaquetado no podríamos, porque lo que tenemos es un nibble para cada dígito, y no un byte (y el ascii es un byte, claro, ya me dirás cómo le sumas 48 si no).

  • DAA (Decimal Adjust AL after Addition) {S,Z,A,P,C}
  • Algo parecido a AAA, sólo que se usa tras la suma de dos bytes con dígitos BCD empaquetados (dos números por tanto de dos dígitos). En vez de sumar 6 a AH suma 6 al nibble alto de AL, etc. Para sumar dos números de dos dígitos BCD empaquetados, ADD AL,loquesea y luego DAA.

  • AAS (Adjust AX After Subtraction) {A,C}
  • Como AAA pero para ajustar después de una resta, en vez de una suma.

  • DAS (Decimal Adjust AL after Subtraction) {S,Z,A,P,C}
  • Análoga a DAA, pero para la resta.

  • AAM (ASCII Adjust AX After Multiply) {S,Z,P}
  • De la misma calaña que AAA,AAS. Ajusta el resultado guardado en AX de multiplicar dos dígitos BCD no empaquetados. Por ejemplo, si AL=07h y BL=08h, tras MUL BL y AAM tendremos AX=0506h (porque 7·8=56). Apasionante.

  • AAD (ASCII Adjust AX Before Division) {S,Z,P}
  • Más de lo mismo. Pero ojo, que ahora AAD se aplica antes de dividir, y no después. Volviendo al ejemplo anterior, si con nuestros AX=0506h, BL=08h hacemos AAD y luego DIV BL obtenemos.. ajá, AL=07h y BL=08h, lo que confirma nuestra teoría de que 7·8=56.

    -MISCELÁNEA

  • HLT (HaLT, parada)
  • Detiene el microprocesador hasta la llegada de una interrupción o de una señal de reset. Se encuentra entre las instrucciones denominadas privilegiadas.

  • NOP (No OPeration, no hacer nada)
  • No hace nada más que consumir los ciclos de reloj necesarios para cargar la instrucción y procesarla. Corresponde a la codificación de XCHG AX,AX

  • INT inmediato
  • Salta al código de la interrupción indicada por el operando inmediato (0-255). Aunque hay un capítulo que explica más en detalle las interrupciones, se puede decir que una instrucción de este tipo realiza una llamada lejana a una subrutina determinada por un cierto número. En el caso de Linux, una interrupción 80h cede la ejecución al sistema operativo, para que realice alguna operación indicada a través de los valores de los registros (abrir un archivo, reservar memoria, etc). La diferencia fundamental con las subrutinas es que al estar numeradas no es necesario conocer la posición de memoria a la que se va a saltar. La característica "estrella" de estas llamadas cuando tienen lugar en modo protegido es que se produce un cambio de privilegio; en el caso citado, el sistema operativo contará con permisos para realizar determinadas operaciones que el usuario no puede hacer directamente. Por ejemplo, si queremos acceder al disco para borrar un archivo, no podemos. Lo que hacemos es cederle el control al sistema diciéndole que borre ese archivo, pues sí que tiene permiso para ese tipo de acceso (al fin y al cabo es quien controla el cotarro), y comprobará si ese archivo nos pertenece, para borrarlo.

    INTO, sin operandos, es mucho menos usada. Genera una interrupción 4 si el bit de overflow está a 1. En caso contrario continúa con la instrucción siguiente, como cualquier salto condicional.

    Me dejo algunas instrucciones que no usaremos ya no digamos normalmente, si no en el 99.999..% de los casos. LOCK bloquea el bus para reservarlo, allí donde pueda haber otros aparatos (como otros procesadores, si el ordenador los tiene) que puedan meterle mano. De este modo se garantiza el acceso; pone el cartel de "ocupado" por una determinada línea del bus, y hace lo que tiene que hacer sin ser molestado. Cada vez que queramos hacer algo de esto, colocaremos el prefijo "LOCK" delante de la instrucción que queramos "blindar". Tan pronto se ejecuta la instrucción, el bloqueo del bus se levanta (a menos que estemos todo el día dando la vara con el prefijo).

    Relacionado con los coprocesadores tenemos ESC, que pasa a esos dispositivos operandos que puedan necesitar (para acceder a registros, por ejemplo). Aquí no pienso hacer nada explícitamente con esta instrucción (ya veremos lo que quiero decir con esto cuando lleguemos al coprocesador matemático), así que puedes olvidar este párrafo.

    Para finalizar, la instrucción WAIT, que detiene el procesador hasta que le llegue una cierta señal. Se utiliza para sincronizar las operaciones entre el procesador y el coprocesador matemático, y también comprobaremos que no hace falta preocuparse de ella.

    Regresar al índice