por Dario Alejandro Alpern
Hola. Mi nombre es Darío Alpern y hoy vamos a ver Introducción a Assembler de procesadores Intel.
Luego de terminar el reset, todos los microprocesadores ejecutan código de máquina que obtienen de memoria hasta que se van a dormir o se apagan.
Aquí se puede ver un ejemplo de código de máquina que no sabemos qué es lo que hace. A la izquierda se muestran las direcciones lógicas que voy a explicar más adelante. A la derecha, los bytes que forman el código de maquina agrupados de a 8 bytes.
Para entender qué es lo que va a ejecutar el procesador, todos los fabricantes publican el assembler, que es una traducción legible para humanos de lo que hace el microprocesador. Una instrucción de código de máquina corresponde a una instrucción en assembler.
A la izquierda se pueden ver las direcciones lógicas, en el centro los bytes que componen el código de máquina para cada instrucción, y a la derecha la decodificación en instrucciones de assembler.
A su vez la instrucción en código de máquina se puede subdividir en tres partes: prefijos en azul, códigos de operación en rojo y operandos en negro.
Lo que se ve es una subrutina que halla la raíz cuadrada del número que se encuentra en la posición de memoria 0300 y almacena el resultado en la posición de memoria 0304.
Aquí se ven los 8 registros de 32 bits de uso general del procesador. Estos registros sirven para el almacenamiento temporario de información y tienen la ventaja de ser mucho más rápido que usar la memoria principal del sistema.
Los procesadores actuales tienen 16 registros de uso general de 64 bits, pero sólo se pueden acceder en el llamado modo 64 bits.
Con cualquiera de estos registros se pueden realizar operaciones aritméticas y lógicas, pero hay instrucciones que solo pueden usar algunos de estos registros. Por ejemplo el registro ESP siempre se usa como puntero de pila, la multipllicación usa EDX y EAX o bien DX y AX, etc.
Los 8 registros de 32 bits se llaman EAX, EBX,.. ESP. Se puede acceder a los 16 bits menos significativos usando los nombres AX, BX, ... SP.
A su vez AX, BX, CX y DX pueden subdividirse en un par de registros de 8 bits cada uno. Por ejemplo AX se subdivide en AH (parte alta) y AL (parte baja).
El procesador permite dividir el espacio direccionable en áreas llamadas segmentos. Estos segmentos se apuntan mediante alguno de los 6 registros CS, DS, ES, FS, GS y SS.
CS es el segmento de código, lo que significa que el código ejecutable debe estar en este segmento.
DS es el segmento de datos. En dicho segmento se encontrarán variables, buffers, etc.
Hay tres registros de segmentos de datos adicionales: ES, FS y GS.
SS es el segmento de pila. La pila debe estar ubicada en este segmento.
Cada registro de segmento posee cuatro campos: selector, base, límite y atributos. El programador sólo puede acceder al campo selector. La lectura del registro de segmento provoca la lectura de su campo selector. La escritura del registro de segmento provoca la escritura del campo selector con ese valor y automáticamente se escribe el resto de los campos.
El campo base indica la dirección lineal donde comienza el segmento en memoria. El campo límite indica el máximo offset permitido para ese segmento. Es decir que es igual a la longitud del segmento menos 1. El campo atributos indica los accesos que posee el procesador a dicho segmento.
Los números que figuran en los diferentes campos indican los valores que el reset carga en los registros de segmento. Los campos en blanco indican que el valor no está definido.
Este es un registro de indicadores de un bit cada uno (excepto IOPL que tiene dos bits). La parte baja de EFLAGS se denomina FLAGS.
Algunos de estos flags se modifican en las instrucciones aritméticas y lógicas y los saltos condicionales modifican el flujo del programa según el valor de estos flags.
Los flags que se usan en modo protegido se verán en otra clase.
Los otros flags son:
Bit 11: Overflow. Indica que hubo sobrepasamiento en un cálculo de números en complemento a dos. Por ejemplo 60 hexadecimal es positivo en complemento a dos porque el bit más significativo es cero. Si sumo 60 hexa más 60 hexa, el resultado es C0 hexa, que tiene el bit más significativo a uno, es decir que se interpreta como negativo en complemento a dos. Como el cálculo es positivo + positivo = negativo, se prende el flag de overflow.
Bit 10: Dirección: Sirve para instrucciones de cadena, estilo memcpy o memset. Si vale cero, las direcciones fuente y destino se incrementan. Si vale uno, se decrementan. Esto sirve para evitar el efecto dominó cuando el buffer de destino se solapa con el buffer fuente.
Bit 9: habilitación de interrupción. Si vale uno, el procesador reconoce las interrupciones que vienen por la pata de interrupt request. Si vale cero, el procesador ignora dichas interrupciones.
Bit 8: trace. Sirve para la ejecución paso a paso en debuggers. Cuando está encendido, se ejecuta la excepción 1 luego de ejecutar la instrucción en curso.
Bit 7: signo: copia el bit más siginificativo del resultado de la operación aritmética o lógica.
Bit 6: cero: vale uno si el resultado de la operación aritmética o lógica vale cero.
Bit 4: acarreo auxiliar: sirve para realizar operaciones en BCD, cuando hay acarreo o préstamo del bit 3 al 4.
Bit 2: bit de paridad: se enciende cuando la cantidad de bits a 1 del resultado de la operación aritmética o lógica es par.
Bit 0: acarreo (carry en inglés): se enciende cuando hay sobrepasamiento en aritmética de números no signados. Por ejemplo si resto 40 hexa menos 60 hexa, se enciende el bit.
El par de registros CS:EIP apunta a la dirección lógica de la próxima instrucción a ejecutar.
Para acceder a memoria, el programador usa la dirección lógica, que está dado por un selector y un offset. En el caso de acceso a datos, las instrucciones usan prefijos de segmentos para indicar a cuál de los seis registros de segmento se refiere la instrucción. Si el prefijo no está presente, se usa un segmento por defecto, que se va a ver en la próxima página.
El cálculo de la dirección lineal se realiza sumando el campo base del registro de segmento más el offset que se encuentra en la instrucción.
El procesador tiene varios modos de operación. Cuando arranca, luego del reset, se encuentra en modo real. En ese caso la base del segmento se calcula multiplicando el selector por 10 hexadecimal.
Como el tamaño de los segmentos están limitados a 64 KB como se vio anteriormente, no es posible acceder a los 4 GB de direccionamiento en modo real. La dirección máxima apenas supera 1 MB.
Las instrucciones que definen offsets pueden usar registros de 16 o de 32 bits.
Para el caso de 16 bits, el offset o dirección efectiva se calcula como la suma de base, índice y desplazamiento. Donde la base puede ser el registro BX, el registro BP o nada. El índice puede ser el registro SI, el registro DI o nada. El desplazamiento es un entero de 16 bits.
Se pueden ver cuatro ejemplos. En los ejemplos de la derecha se usa el segmento por defecto, que en ambos casos es DS, porque la base es el registro BX.
En el segundo ejemplo el registro CX tiene 16 bits, por lo que el procesador lee dos posiciones de memoria a partir de la dirección indicada.
En el último ejemplo hay que especificar el tamaño del operando, indicando que se leen 4 bytes mediante la palabra DWORD. Otras posibilidades son BYTE para un byte y WORD para dos bytes. Esto ocurre cuando no hay un operando con un registro que especifique el tamaño del operando en memoria. En casi todas las instrucciones de dos operandos coinciden los tamaños de ambos operandos.
Es importante tener en cuenta que en las instrucciones de dos operandos, el de la izquierda es el destino y el de la derecha es el fuente. El resultado de la operación se carga en el destino.
En el caso de 32 bits se agrega un multiplicador. La base puede ser cualquiera de los 8 registros de uso general: EAX, EBX, etc. o nada. El multiplicador puede ser 1, 2, 4 u 8. El índice puede ser cualquiera de los registros excepto ESP o nada. Finalmente, el desplazamiento es un entero de 32 bits.
Se pueden ver cuatro ejemplos. Exceptuando el primer caso, los demás usan el segmento por defecto, que es SS en el ejemplo de abajo a la izquierda y DS en los otros casos.
Para entender como funciona la subrutina para hallar la raíz cuadrada, vamos a escribir los primeros cuadrados perfectos.
Y ahora escribimos las diferencias entre dos cuadrados perfectos consecutivos. Podemos ver que son los números impares: 1, 3, 5, 7, etc.
Eso significa que para hallar la raíz cuadrada, basta con restar los números impares sucesivos hasta que el resultado dé negativo. Entonces la raíz cuadrada es la cantidad de restas menos 1.
Por ejemplo, si quiero hallar la raíz cuadrada de 10, le resto 1 (me da 9), luego 3 (me da 6), luego 5 (me da 1), luego 7 (me da -6) y paro ahí porque me dio negativo. Como hice cuatro restas, la raíz cuadrada de 10 es 3.
Ahora vamos a ver el programa en Assembler.
Aquí se puede ver instrucciones como las que vimos en el desensamblado, pero en el programa en assembler que escribimos nosotros, también hay comentarios, que se preceden con punto y coma, directivas (como EQU y USE16) que son comandos para el ensamblador, etiquetas (como Inicio y colgarse) que son el destino de saltos o llamadas a subrutinas.
En esta parte del programa se puede ver la inicialización del puntero de pila en la dirección 0: 8000 hexa y la carga del radicando con el valor 76. Luego se llama a la rutina de cálculo de la raíz cuadrada y finalmente el código hace un ciclo indefinido de saltos (jump) ya que como dijimos al principio, el procesador ejecuta instrucciones a toda velocidad hasta que se va a dormir o se apaga.
A continuación aparece la subrutina raiz_cuadrada. Luego de leer el valor del radicando y poner a uno el sustraendo haciendo XOR del registro consigo mismo poniéndolo a cero y luego sumándole 1 con la instrucción INC. En la línea 26 inicializamos a cero la cantidad de restas.
Luego comienza el ciclo que realiza las sustracciones. Primero restamos al acumulador el número impar que corresponda (sea 1, 3, 5, etc.).
En la línea 29 saltamos si el carry flag vale 1. Eso quiere decir que saltamos si el resultado de la resta es menor que cero, ya que en ese caso no hay que hacer más restas.
Siguendo con el ciclo, en la línea 30 hallamos el siguiente número impar sumando 2 y luego incrementamos la cantidad de restas.
Finalmente con la instrucción JMP saltamos al principio del ciclo (a la línea 22).
El procesador llega a la línea 33 cuando no hay más restas por hacer. Como el valor del registro DX es la cantidad de restas que se hicieron menos 1, guardamos ese valor en memoria en la posición RAIZ. Finalmente terminamos la subrutina con la instrucción RET para volver al programa principal.
El valor inicial de EIP en el reset es FFF0 hexadecimal, por lo que en la línea 37 llenamos con ceros la memoria de código hasta llegar a dicho offset. La directiva times repite la cantidad de veces que está a continuación la instrucción o directiva que sigue a esa cantidad. El valor de $ es el offset que el ensamblador está procesando. De esta manera $ - Inicio es la cantidad de bytes en el segmento de código antes de ensamblar la directiva times. Como queremos ensamblar la instrucción de arranque en el offset FFF0 hexadecimal, restamos FFF0 hexacimal menos $-Inicio para obtener la cantidad de bytes de relleno. Así la cantidad de bytes de programa más el relleno vale FFF0 hexadecimal. La directiva db 0 inserta un byte a cero. Entonces luego de la directiva times, el ensamblador debe procesar el offset FFF0 hexadecimal.
La última instrucción salta al principio del programa principal. $$ apunta al principio del programa.
La directiva que se encuentra en la línea 44 hace algo parecido al de la línea 37 agregando relleno para completar los 64 KB de memoria ejecutable.
Ahora vamos a compilar el programa.
No hubo ningún error ni advertencia (warning en inglés), como corresponde. Con el siguiente comando verificamos que el binario tenga exactamente 64 kilobytes, es decir 65536 bytes.