



(107 opiniones)
Ejemplo de programación con saltos condicionales
A estas alturas del curso de ensamblador, creo que estamos abusando mucho de la teoría; ciertamente esto es ante todo teoría, pero no está de más ver un ejemplo práctico de programa en el que usamos saltos condicionales y etiquetas. El programa escrito a continuación, imita una operación de multiplicación utilizando tan sólo la suma, resolviéndolo mediante el algoritmo de que N * M es lo mismo que (N+N+N+...+N), M veces:
; Suponemos que EAX contiene N, y EBX, contiene M. xor edx,edx ; Aquí vamos a almacenar el resultado final. La operación xor edx,edx hace EDX = 0. LoopSuma: ; Esto, es una etiqueta add edx,eax ; A EDX, que contendrá el resultado final, le sumamos el primer multiplicando dec ebx jnz LoopSuma ; Si el resultado de decrementar el multiplicando EBX es cero, no sigue sumando el factor de EAX.
Un programa tan sencillo como este, nos dará en EDX el producto de EAX y EBX. Veamos uno análogo para la división:
; Suponemos que EAX contiene el dividendo y EBX el resto. xor ecx,ecx ; ecx contendrá el cociente de la división xor edx,edx ; edx va a contener el resto de la división RepiteDivision: inc ecx ; incrementamos en 1 el valor del cociente que queremos obtener sub eax,ebx ; al dividendo le restamos el valor del divisor cmp eax,ebx ; comparamos dividendo y divisor jna RepiteDivision ; si el divisor es mayor que el dividendo, ya hemos acabado de ver el cociente mov edx,eax
Se ve desde lejos que este programa es muy optimizable; el resto quedaba en EAX, con lo que a no ser que por algún motivo en particular lo necesitemos en EDX, podríamos prescindir de la última línea y hacer que el cociente residiera en ECX mientras que el resto sigue en EAX. También sería inútil, la línea "xor edx,edx" que pone EDX a cero, dado que luego es afectado por un "mov edx,eax" y da igual lo que hubiera en EDX.
Hemos visto, además, cómo hacer un bucle mediante el decremento de una variable y su comprobación de si llega a cero, y en el segundo caso, mediante la comprobación entre dos registros; para el primer caso vamos a tener en el ensamblador del PC un método mucho más sencillo utilizando ECX como contador como va a ser el uso de la instrucción LOOP, que veremos más adelante, y que es bastante más optimizado que este decremento de a uno.
La pila
PUSH, POP y demás fauna
La pila es una estructura de datos cuya regla básica es que "lo primero que metemos es lo último que sacamos". El puntero que indica la posición de la pila en la que estamos es el SS:ESP, y si pudiéramos verlo gráficamente sería algo como esto:

¿Qué significa este dibujo? Que SS:ESP está apuntando a ese byte de valor 91h; los valores que vienen antes no tienen ninguna importancia (y dado que esta misma pila es utilizada por el sistema operativo cuando se produce una interrupción, es improbable que podamos considerar "fijos" estos valores que hayan en el lugar de las interrogaciones).
La primera instrucción que vamos a ver y que opera sobre la pila, es el PUSH, "empujar". Sobre el dibujo, un PUSH de 32 bits (por ejemplo un PUSH EAX) será una instrucción que moverá "hacia atrás" el puntero de pila, añadiendo el valor de EAX allá. Si el valor del registro EAX fuera de 0AABBCCDDh, el resultado sobre esta estructura de un PUSH EAX sería el siguiente:

Un par de cosas a notar aquí: por una parte sí, el puntero se ha movido sólo (y seguirá moviéndose hacia la izquierda - hacia "atrás" - si seguimos empujando valores a la pila). Por otra, quizá resulte extraño que AABBCCDDh se almacene como DDh, CCh, BBh, AAh, es decir, al revés. Pero esto es algo común; cuando guardamos en alguna posición de memoria un dato mayor a un byte (este tiene cuatro), se van a almacenar "al revés"; este tipo de ordenación, se llama little endian, opuesta a la big endian que almacena directamente como AAh BBh CCh DDh un valor así.
La instrucción PUSH, en cualquier caso, no está limitada a empujar el valor de un registro: puede empujarse a la pila un valor inmediato (p.ej, PUSH 1234h), y pueden hacerse referencias a memoria, como PUSH [EBX+12].
Otra instrucción bastante importante es PUSHF (y PUSHFD), que empujan el contenido del registro de Flags en la pila (un buen modo de que lo podamos sacar a un registro y lo analicemos). Como se indica en el gráfico de los Flags en su capítulo correspondiente, PUSHFD empuja los EFlags (flags extendidos, 32 bits), y PUSHF los Flags (los 16 bits menos significativos de este registro).
Ahora, no sólo querremos meter cosas en la pila, estaría interesante poder sacarlas y tal. Para ello, también tenemos una instrucción, el POP, que realiza la acción exáctamente opuesta al PUSH. En particular, va a aumentar el puntero ESP en cuatro unidades y al registro o posición donde se haga el POP, transferir los datos a los que se apuntaba. En el caso anterior, volveríamos a tener el puntero sobre el 91h:

Ya no podemos fiarnos de que el contenido de posiciones anteriores sigue siendo DDh,CCh,BBh,AAh. En cuanto el procesador haga una interrupción va a usar la pila para almacenar datos, luego serán sobreescritos. Si nuestra órden hubiera sido un POP ECX, ahora ECX contendría el valor 0AABBCCDDh.
Otra cosa a tener en cuenta, es que la pila no es más que una estructura fabricada para hacernos más fácil la vida; pero no es una entidad aparte, sigue estando dentro de la memoria principal. Por ello, además de acceder a ella mediante ESP, podríamos acceder con cualquier otro registro sin tener que utilizar las órdenes PUSH/POP. Esto no es usual, pero es bueno saber al menos que se puede hacer. Si en una situación como la del último dibujo hacemos un MOV EBP, ESP y un MOV EAX, SS:[EBP], el registro EAX pasará a valer 07A5F0091h.
Destacan también otras dos instrucciones: PUSHA y POPA. Estas instrucciones lo que hacen es empujar/sacar múltiples registros (por si tenemos que salvarlos todos, resultaría un coñazo salvarlos uno a uno). Exáctamente, estas dos instrucciones afectan a EAX, EBX, ECX, EDX, EBP, ESI y EDI.
Subrutinas
Introducción al uso de subrutinas
Es bastante común utilizar subrutinas al programar; podemos verlas como el equivalente a las funciones, de tal forma que se puedan llamar desde cualquier punto de nuestro programa. Supongamos que nuestro programa tiene que recurrir varias veces a un mismo código dedicado, por ejemplo, a averiguar el producto de dos números como hacíamos en el ejemplo de código anterior (vale, el ensamblador del 80x86 admite multiplicación, pero repito que esto es un ejemplo :P).
La instrucción que vamos a utilizar para "llamar" a nuestra función de multiplicar, es el CALL. CALL admite, como es habitual, referencias directas a memoria, a contenidos de registros y a contenidos de direcciones de memoria apuntadas por registros. Podríamos hacer un CALL EAX, un CALL [EAX] o directamente un CALL 12345678h. Al programar, utilizaremos normalmente un CALL , que ya se encargará el compilador de traducir a dirección inmediata de memoria.
Luego, dentro de la propia rutina tenemos que devolver el control al código principal del programa, esto es, al punto en el que se había ejecutado un CALL. Esto se hace mediante la instrucción RET, que regresará al punto en que se llamó ejecutándose después la instrucción que venga a continuación.
Como ejemplo con el código anterior:
> mov eax,<valor1> mov ebx,<valor2> call Producto > [...] ; Suponemos que EAX contiene N, y EBX, contiene M.
|
Producto: | |
|
xor edx,edx = 0. |
; Aquí vamos a almacenar el resultado final. La operación xor edx,edx hace EDX |
|
LoopSuma:add edx,eax dec ebx |
; Esto, es una etiqueta ; A EDX, que contendrá el resultado final, le sumamos el primer multiplicando |
|
jnz LoopSuma |
; Si el resultado de decrementar el multiplicando EBX es cero, no sigue sumando el factor de EAX.
|
Cómo funcionan CALL/RET
Cuando llamamos a una subrutina, en realidad internamente está pasando algo más que "pasamos el control a tal punto"; pensemos que se pueden anidar todas las subrutinas que queramos, es decir, que pueden hacerse CALLs dentro de CALLs sin ningún problema.
¿Por qué? Pues por la forma en que funcionan específicamente estas instrucciones: -CALL, lo que realmente está haciendo es empujar a la pila la dirección de ejecución de la instrucción siguiente al CALL, y hacer un JMP a la dirección indicada por el CALL. Así, al inicio de la subrutina la pila habrá cambiado, y si hiciéramos un POP , sacaríamos la dirección siguiente a la de desde donde se llamó.
-RET, lo que va a hacer es sacar de la pila el último valor que encuentre (nótese que no sabe que ese sea el correcto, con lo que si en medio de la subrutina hacemos un PUSH o un POP sin controlar que esté todo al final tal y como estaba al principio, el programa puede petar), y saltar a esa dirección. En caso de que no hayamos hecho nada malo, va a volver donde nosotros queríamos.
Jugar con esto nos va a ser muy necesario cuando programemos virus. Hay un sistema muy standard de averiguar la dirección actual de memoria en que se está ejecutando el programa (y que es necesario utilizar normalmente, a no ser que lo hagamos por algún otro método), que funciona como sigue:
call delta_offset ; normalmente este método se llama "delta offset", que hace referencia a esta dirección. delta_offset: pop ebp ; Ahora ebp tiene la dirección de memoria indicada por "delta_offset" en el momento actual.
No abundaré en más detalles; sólo, que esta es la mejor forma de saber cuánto vale el registro EIP, lo cual nos va a ser de bastante utilidad al programar.
Funciones avanzadas en CALL/RET
Para terminar, tenemos que hablar de la existencia de otra forma de RET que es IRET, retorno de interrupción; la trataremos en el siguiente apartado junto con el uso de interrupciones por ser un tanto especial.
Por otro lado, a veces veremos una opción que puede parecernos "extraña", y es que a veces el RET viene acompañado de un número, por ejemplo, RET 4. El número que viene junto con la instrucción, indica que además de sacar el valor de retorno de la pila tenemos que aumentar el valor del puntero de pila en tantas unidades como se indique (téngase en cuenta que 4, p.ej, representan 32 bits, o sea, un registro).
¿Cuál es el sentido de esto? Bien, una forma estándar de llamar a funciones consiste en lo siguiente: si tenemos que pasarle parámetros, lo que hacemos es empujarlos en la pila y después llamar a la función. Leemos los valores de la pila dentro de la subrutina sin cambiar el puntero de pila, y cuando queramos regresar no sólo queremos que el RET saque su dirección de retorno sino que además la pila aumente lo suficiente como para que la pila vuelva a estar en su lugar, como si no hubiéramos empujado los parámetros.
Es decir, pongamos que hacemos PUSH EAX y PUSH EBX y luego un CALL . En esta leemos directamente los valores empujados a la pila con un MOV ,[ESP+4] y MOV ,[ESP+8] (sí, podemos leer así de la pila sin problemas y sin modificar ESP). Ahora, al volver queremos que la pila se quede como estaba antes de ejecutar el primer PUSH EAX. Pues bien, entonces lo que hacemos es escribir al final de la subrutina un RET 8, lo que equivale a los dos registros que habíamos empujado como parámetros.
Como tampoco me voy a morir si lo hago, adaptaré el código anterior a esta forma de hacer las cosas (que personalmente no es que me guste mucho pero vamos, el caso es que se usa...)
> mov eax,<valor1> mov ebx,<valor2> push eax push ebx call Producto > [...] ; Suponemos que EAX contiene N, y EBX, contiene M. Producto: mov eax,dword ptr [ESP+8] mov ebx,dword ptr [ESP+4] xor edx,edx ; Aquí vamos a almacenar el resultado final. La operación xor edx,edx hace EDX = 0. LoopSuma: ; Esto, es una etiqueta add edx,eax ; A EDX, que contendrá el resultado final, le sumamos el primer multiplicando dec ebx jnz LoopSuma ; Si el resultado de decrementar el multiplicando EBX es cero, no sigue sumando el factor de EAX.
|