MOVs condicionales
Una nueva característica presente a partir de algunos modelos de Pentium Pro y en siguientes procesadores de Intel, y en AMD a partir de K7 y posiblemente K6-3, son los MOVs condicionales; esto es, que se realizan si se cumple una determinada condición. La instrucción es
CMOVcc, donde "cc" es una condición como lo es en los saltos condicionales (ver más adelante), p.ej
CMOVZ EAX, EBX.
No obstante, de momento no recomendaría su implementación; aunque terriblemente útil, esta instrucción no es standard hasta en procesadores avanzados, y podría dar problemas de compatibilidad. Para saber si el procesador tiene disponible esta operación, podemos ejecutar la instrucción
CPUID, la cual da al programador datos importantes acerca del procesador que está corriendo el programa, entre otras cosas si los MOVs condicionales son utilizables.
Codificación de una instrucción
Ahora que ya sabemos utilizar nuestra primera instrucción en lenguaje ensamblador puede surgir una duda: ¿cómo entiende esta instrucción el procesador?. Es decir, evidentemente nosotros en la memoria no escribimos las palabras "MOV EAX, EBX", sin embargo esa instrucción existe. ¿Cómo se realiza pues el paso entre la instrucción escrita y el formato que la computadora sea capaz de entender?.
En un programa, el código es indistinguible de los datos; ambos son ristras de bits si no hay nadie allí para interpretarlos; el programa más complejo no tendría sentido sin un procesador para ejecutarlo, no sería más que una colección de unos y ceros sin sentido. Así, se establece una convención para que determinadas cadenas de bits signifiquen cosas en concreto.
Por ejemplo, nuestra instrucción "MOV EAX, EBX" se codifica así:
08Bh, 0C3h
Supongamos que EIP apunta justo al lugar donde se encuentra el 08Bh. Entonces, el procesador va a leer ese byte (recordemos que cada cifra hexadecimal equivale a 4 bits, por tanto dos cifras hexadecimales son 8 bits, o sea, un byte). Dentro del micro se interpreta que 08Bh es una instrucción MOV r32,r/m32. Es decir, que dependiendo de los bytes siguientes se va a determinar a qué registro se va a mover información, y si va a ser desde otro registro o desde memoria.
El byte siguiente, 0C3h, indica que este movimiento se va a producir desde el registro EBX al EAX. Si la instrucción fuera "MOV EAX, ECX", la codificación sería así:
08Bh, 0C1h
Parece que ya distinguimos una lógica en la codificación que se hace para la instrucción "MOV EAX,algo". Al cambiar EBX por ECX, sólo ha variado la segunda cifra del segundo byte, cambiando un 3 por un 1. Podemos suponer entonces que se está haciendo corresponder al 3 con EBX, y al 1 con ECX. Si hacemos más pruebas, "MOV EAX,EDX" se codifica como
08Bh, 0C2h. "MOV EAX,ESI" es
08BH, 0C6h y "MOV EAX,EAX" (lo cual por cierto no tiene mucho sentido), es
08Bh, 0C0h.
Vemos pues que el procesador sigue su propia lógica al codificar instrucciones; no es necesario que la entendamos ni mucho menos que recordemos su funcionamiento. Sencillamente merece la pena comprender cómo entiende aquello que escribimos. Para nosotros es más fácil escribir "MOV EAX, EBX" puesto que se acerca más a nuestro lenguaje; MOV recuerda a "movimiento", al igual que "ADD" a añadir o "SUB" a restar. Al computador "MOV" no le recuerda nada, así que para él resulta mucho mejor interpretar secuencias de números; la equivalencia entre nuestro "MOV EAX,EBX" y su "08Bh, 0C3h" es exacta, la traducción es perfecta y procesador y humano quedan ambos contentos.
El sentido pues de este apartado es entender cómo va a funcionar cualquier programa que escribamos en lenguaje ensamblador; cuando escribamos nuestros programas, utilizaremos un compilador: una especie de traductor entre la
notación en ensamblador que más se parece a nuestro lenguaje con instrucciones como "MOV EAX, EBX" y la
notación en bits, la que la máquina entiende directamente. En realidad, podríamos considerar que
ambos son el mismo lenguaje; la única diferencia es la forma de representarlo.
Por supuesto, quien quiera meterse más a fondo en esto puede disfrutar construyendo instrucciones por sí mismo jugando con estos bytes; es algo interesante de hacer en virus cuando tenemos engines polimórficos, por ejemplo. Hay, de hecho, listas muy completas acerca de cómo interpretar la codificación en bits que entiende la máquina, que pueden ser consultadas sin problemas (en la propia web de Intel vienen toda una serie de tablas indicando cómo se hace esto con todas y cada una de las instrucciones que entienden sus procesadores).
Las operaciones lógicas
Las operaciones con registros se dividen en dos tipos: aritméticas y lógicas. A las aritméticas estamos muy acostumbradas, y son la suma, la resta, multiplicación, división... las lógicas operan a nivel de bit, lo que las distingue de las aritméticas (si "a nivel de bit" resulta algo oscuro, da igual, seguid leyendo).
Aunque hayamos mencionado cuáles son estas operaciones lógicas en el primer capítulo, volvemos a repasarlas una a una y con detalle:
AND
El AND lógico realiza bit a bit una operación consistente en que el bit resultado es 1 sólo si los dos bits con los que se opera son 1. Equivale a decir que el resultado "es verdad" si lo son los dos operandos.
Actuará así con cada uno de los bits de los dos operandos, almacenando en el de destino el resultado. Por ejemplo:
10001010 AND 11101010
10001010
La forma de utilizar el AND es muy similar al MOV que ya hemos visto; algunas formas de utilizarlo podrían ser
AND EAX,EBX, o
AND EAX,[1234h], o
AND ECX,[EDX], etc. El resultado se almacena en el operando de destino, esto es, EAX en los dos primeros casos y ECX en el tercero.
OR
El OR lógico también opera bit a bit, poniendo el resultado a 1 si al menos uno de los dos bits con los que operamos están a 1, siendo lo mismo que decir que el resultado es "cierto" si lo es al menos uno de sus constituyentes.
Almacenará, como el AND, el resultado en el operando de destino:
10001010 OR 11101010
11101010
La forma de utilizarlo es el común a todas las operaciones lógicas, como el AND mencionado anteriormente.
XOR
La operación XOR, operando bit a bit, da como resultado un 1 si uno y sólo uno de los dos bits con los que se opera valen 1, es por ello que se llama OR exclusivo o eXclusive OR:
10001010 XOR 11101010
01100000
NOT
Esta operación sólo tiene un operando, puesto que lo que hace es invertir los bits de este operando que evidentemente será de destino:
NOT 11101010 00010101
Operaciones aritméticas
En los procesadores 80x86, tenemos una buena gama de operaciones aritméticas para cubrir nuestras necesidades. Estas son, básicamente:
ADD
ADD significa añadir. Tendremos con esta instrucción las posibilidades típicas de operación; sobre memoria, sobre registros, y con valores inmediatos (recordando que no podemos operar con dos posiciones de memoria y que el destino no puede ser un valor inmediato). Así, un ejemplo sería:
ADD EAX, 1412h
Algo tan sencillo como esto añade 1412h hexadecimal a lo que ya hubiera en EAX, conservando el resultado final en EAX. Por supuesto podemos usar valores decimales (si quitamos la h a 1412h, sumará 1412h decimal... creo que no lo mencioné, pero esto vale siempre, tanto para MOV como para cualquier otra operación lógica o aritmética). Otros ejemplos podrían ser
ADD ECX, EDI (sumar ECX y EDI y almacenar el resultado en ECX),
ADD dword ptr [EDX], ESI (coger lo que haya en la dirección de memoria cuyo valor indique EDX, sumarle el valor del registro ESI y guardar el resultado en esa dirección de memoria), etc.
SUB
Esta es la operación de resta; las reglas para utilizarla, las mismas que las del ADD. Tan sólo cabría destacar el hecho de que si estamos restando un número mayor a uno menor, además de una modificación en los FLAGS para indicar que nos hemos pasado, lo que sucederá es que al llegar a 0000 en el resultado el siguiente número será FFFF. Es decir, que al pasarnos por abajo del todo el resultado comienza por arriba del todo.
Supongamos que queremos restar 1 - 2. El resultado no es -1, sino el máximo número representable por la cantidad de bits que tuviéramos. Vamos, que si son 8 bits (que representan un valor entre 0 y 255), el resultado de 1 - 2 será 255. Para los curiosos, este 255 en complemento a 2 equivale al -1, por lo que si operamos en este complemento a 2 la operación de resta tiene completo sentido para los números negativos.
Lo mismo sirve para el ADD cuando sumamos dos números y el resultado no es representable con el número de bits que tenemos. Si hicieramos 255 + 1 y el máximo representable fuera 255 (o FFh en hexadecimal, usando 8 bits), el resultado de 255 + 1 sería 0.
Como decía, las posibilidades para usar el SUB son como las del ADD, con lo que también es válido esto:
SUB EAX, 1412h
Los ejemplos mencionados con el ADD también valen:
SUB dword ptr [EDX], ESI va a restar al contenido de la dirección de memoria apuntada por EDX el valor almacenado en ESI, y el resultado se guardará en esta dirección [EDX].
SUB ECX, EDI restará al valor de ECX el de EDI, guardando el resultado en el registro ECX.
MUL
Pasamos a la multiplicación; aquí el tratamiento es un tanto distinto al que se hacía con la suma y la resta. Sólo vamos a indicar un parámetro que va a ser un registro o una dirección de memoria, y según su tamaño se multiplicará por el contenido de AL (8 bits), AX (16) o EAX (32). El resultado se guardará entonces en AX si se multiplicó por AL, en DX:AX si se multiplicó por AX, y en EDX:EAX si se multiplicó por EAX. Como vemos se utiliza para guardar el resultado el doble de bits que lo que ocupan los operandos; así no se pierde información si sale un número muy grande.
Veamos un ejemplo por cada tamaño:
MUL CL: Coge el valor de CL, lo multiplica por AL, y guarda el resultado en AX.
MUL word ptr [EDX]: Obtiene los 16 bits presentes en la dirección de memoria EDX (ojo, que el tamaño de lo que se escoge lo indica el "
word ptr", EDX sólo indica una dirección con lo que aunque sean 32 bits esto no influye, el tamaño, repito, es determinado por el "word ptr"). Una vez coge esos 16 bits los multiplica por AX, y el resultado se va a guardar en DX:AX. Esto significa, que los 16 bits más significativos los guarda en DX y los 16 menos significativos en AX. Si el resultado de la multiplicación fuera
12345678h, el registro DX contendría
1234h, y el registro AX,
5678h.
MUL ESI: Coge el contenido del registro ESI, y lo multiplica por EAX. El resultado es almacenado en EDX:EAX del mismo modo en que antes se hacía con DX:AX, sólo que esta vez tenemos 64 bits para guardarlo. La parte de más peso, más significativa, se guardará en EDX, mientras que la de menor peso será puesta en EAX. Si el resultado de ESI x EAX fuera
1234567887654321h, EAX contendría
87654321h y EDX
12345678h.
DIV
Por suerte, aunque a quien se le ocurrió esto de los nombres de las instrucciones fuera anglosajón, siguen pareciéndose bastante al castellano; la instrucción DIV es la que se dedica a la división entre números.
El formato de esta instrucción es muy similar al MUL, y va a tener también tres posibilidades, con 8, 16 y 32 bits. En ellas, AX, AX:DX o EAX:EDX van a dividirse por el operando indicado en la instrucción, y cociente y resto van a almacenarse en AL y AH, AX y DX o EAX y EDX, respectivamente:
DIV CL: Se divide el valor presente en AX por CL. El cociente de la división se guardará en AL, y el resto en AH. Si teníamos CL = 10 y AL = 6, al finalizar la ejecución de esta instrucción tendremos que CL no ha variado y que AH = 4 mientras que AL = 1.
DIV BX: Se divide el valor de DX:AX por el de BX. El cociente que resulte de esto se guardará en AX, mientras que el resto irá en DX. El dividendo (DX:AX) está formado de la misma manera en que lo estaba el un MUL el resultado de una operación: la parte más "grande", los bits más significativos, irán en DX mientras que los menos significativos irán en AX.
DIV dword ptr [EDI]: El valor contenido en la combinación EDX:EAX (64 bits) se dividirá por los 32 bits que contiene la dirección de memoria EDI; el cociente de la división se va a guardar en EAX, y el resto en EDX.
INC y DEC
Tan sencillo como INCrementar y DECrementar. Estas dos instrucciones sólo tienen un operando que hace al tiempo de origen y destino, y lo que hacen con él es "sumar uno" o "restar uno":
INC AX: Coge el contenido de AX, le suma 1 y almacena el resultado en AX
DEC dword ptr [EDX]: Obtiene el valor de la posición de memoria a la que apunta EDX, le resta 1 y almacena allí el resultado.
INC y DEC, como veremos cuando lleguemos a los saltos condicionales, se suelen utilizar bastante para hacer contadores y bucles; podemos ir decrementando el valor de un registro y comprobar cuando llega a cero, para repetir tantas veces como indique ese contador un trozo de código, una operación en particular.
Ejecutable, Windows), tenemos el
WinDasm, que tiene bastante bien organizado todo el tema de tablas de importaciones, exportaciones y demás cositas de este tipo de formato. De nuevo, os remito a la página de Darknode para obtenerlos, en esa sección de "Other virus related stuff".
Por último, hay otro tipo de utilidad que nos puede servir, que son los visores hexadecimales. En cierto modo son como debuggers pero que no desensamblan las instrucciones, simplemente nos muestran sus valores hexadecimales y ascii (como la ventana de abajo del Turbo Debugger, si recordáis). Algunos tienen alguna opción maja, aparte que para comprobar algunas cosas rápidamente suelen ser útiles. Mi consejo, pues bajo Linux el
BIEW (
http://biew.sourceforge.net/)∞, y aunque no tengo URL de referencia (probar DarkNode), para Windows el
HIEW o el viejo DiskEdit de las Norton Utilities.