Lenguaje ensamblador x86

El lenguaje ensamblador x86 es la familia de los lenguajes ensambladores para los procesadores de la familia x86 introducida en abril de 1972, que incluye desde los procesadores Intel 8086 y 8088, pasando por los Pentium de Intel y los Athlon de AMD y llegando hasta los últimos procesadores x86 de estas compañías. Como el resto de lenguajes ensambladores, usa una serie de mnemotécnicos para representar las operaciones fundamentales que el procesador puede realizar. Los compiladores a menudo producen código ensamblador como un paso intermedio cuando traducen un programa de alto nivel a código máquina. Considerado como un lenguaje de programación de bajo nivel y específico para cada máquina. Aunque algunas veces es usado para software de aplicación de sistemas de ventanas, los lenguajes ensambladores son utilizados principalmente en aplicaciones críticas como sistemas de arranque, Sistemas Operativos, núcleos y en controladoras de dispositivos, así como en sistemas en tiempo real o pequeños sistemas embebidos.

Historia editar

Los procesadores Intel 8086 y 8088 fueron los primeros de 16 bits en tener un conjunto de instrucciones conocido actualmente como x86. Fueron un paso evolutivo en comparación con la generación anterior de CPUs de 8 bits, como el 8080 y heredaron muchas características e instrucciones, las cuales fueron extendidas para trabajar con 16 bits. Ambos CPUs contenían un bus de direcciones de 20 bits y un grupo de registros internos de 16 bits. El 8086 tenía un bus de datos externo de 16 bits y el 8088 uno de 8 bits. El 8088 estaba previsto como una versión de bajo coste del 8086. El lenguaje ensamblador del x86 también cubre las diferentes versiones de CPU que siguieron, como el 80188 y 80186, 80286, 80386, 80486, Pentium, etc, de Intel, también como los CPU de AMD y Cyrix como los procesadores 5x86 y K6, y el NEC V20 de NEC. El término x86 aplica a cualquier CPU pueda correr el lenguaje ensamblador original (usualmente también correrá por lo menos algunas de las extensiones.)

El moderno conjunto de instrucciones x86 es un superconjunto de las instrucciones del 8086 y el 8088 y una serie de extensiones a este conjunto de instrucciones que comenzaron con el microprocesador Intel 8008. Existe casi una completa compatibilidad binaria desde los chips Intel 8088 y 8086 con los modernos procesadores Intel Pentium 4, Intel Core Duo, Intel Core i7, AMD Athlon 64, AMD Opteron, hasta la generación actual de microprocesadores x86, aunque existen algunas excepciones. Esta compatibilidad se logra gracias al uso de 2 conjuntos de instrucciones de arquitecturas, lo cual es comúnmente criticado. La compatibilidad de los programas en lenguaje ensamblador con procesadores más antiguos sólo es posible cuando el programa no incluye instrucciones solo disponibles en los procesadores nuevos.

Generalmente, cada nuevo procesador de la serie tiene unas cuantas instrucciones adicionales y más capacidades y mejor desempeño que los anteriores. El 286 agregó unas cuantas instrucciones. el modo protegido y capacidad multitarea, el 386 extendió la plataforma de 16 a 32 bits, añadió algunas instrucciones e hizo al conjunto de instrucciones más ortogonal, haciéndolo la base de los procesadores siguientes hasta que aparecieron los de 64 bits. Con el 486 se incorporó el coprocesador numérico en el propio chip, otros procesadores posteriores agregaron instrucciones para acelerar el procesamiento multimedia, multithreading, 2 o más núcleos, 64 bits, etc.

Mnemotécnicos y códigos de operación editar

Cada instrucción del x86 está representada por un mnemotécnico, que traduce directamente a una serie de bytes la representación de la instrucción, llamada código de operación. Por ejemplo, la instrucción NOP se codifica como 0x90 y la instrucción HLT como 0xF4. Algunos códigos de operación no tienen nombres mnemotécnicos y no están documentados. Diferentes procesadores en la familia del x86 pueden interpretar códigos de operación indocumentados de forma distinta, haciendo que un mismo programa se comporte de forma distinta en diferentes procesadores.

Sintaxis editar

El lenguaje ensamblador x86 tiene 2 vertientes diferentes en cuanto a su sintaxis de programación: sintaxis Intel, usada en sus inicios para la documentación de la plataforma x86, y sintaxis AT&T .[1]​ La sintaxis Intel es la dominante en la plataforma Windows, mientras que en Unix/Linux ambas son utilizadas aunque GCC solo soportaba la sintaxis AT&T en sus primeras versiones.

La mayoría de los ensambladores x86 utilizan la sintaxis de Intel, como MASM, TASM, NASM, FASM and YASM. GAS ha soportado ambas sintaxis desde la versión 2.10 a través de la directiva “.intel_sintax”.[1][2][3]

Registros editar

Los procesadores x86 tienen una serie de registros disponibles para almacenar información. Este conjunto de registros son conocidos como registros de propósito general o GPR (del inglés General Purpose Register).

Además de los GPR, existen adicionalmente:

El registro IP apunta a la posición del programa en la que el procesador está ejecutando el código. EL registro no puede ser accedido por el programador directamente.

Los registros del x86 pueden ser usados mediante la instrucción MOV. Por ejemplo:

   mov ax, 1234h
   mov bx, ax

copia el valor 1234h en el registro ax y en la siguiente línea copia el valor de ax en el registro bx.

Direccionamiento segmentado editar

La arquitectura x86 utiliza el método de segmentación para direccionar memoria, en lugar del método lineal usado en otras arquitecturas. La segmentación implica descomponer una dirección lineal en dos partes – un “segmento” y un “desplazamiento”. El segmento apunta al inicio de un bloque de 64K direcciones y el desplazamiento indica la diferencia entre el lugar apuntado y el inicio del segmento.

Este modo de direccionamiento se utiliza para aprovechar las características del procesador. El problema estaba en que los registros internos del procesador eran de 16 bits, mientras que el bus de direcciones era de 20. Faltaban por tanto 4 bits para poder aprovechar al máximo las capacidades de direccionamiento del procesador. Para resolver esto, cada dirección de memoria será especificada como un segmento y un desplazamiento dentro de ese segmento. Esta solución divide la memoria en segmentos de 64 K, lo cual limitó bastante los diseños de los procesadores posteriores de la familia (Intel 80286, Intel 80386, etc.); aunque posteriormente se idearon métodos para resolver este problema, como la memoria extendida (no compatible con el x86/x88). Con esto se consigue que el procesador sea capaz de direccionar 1,048,576 direcciones de 1 byte, o lo que es lo mismo, 1Mbyte.

Se utilizan dos registros para el direccionamiento: uno para indicar el segmento, y el otro para indicar el desplazamiento.

Para obtener la dirección de memoria (dirección efectiva): se toma el valor de registro de segmento, se desplaza 4 bits a la izquierda (multiplicación por 16), y se le suma el valor del registro de desplazamiento.

Ejemplo: Si DS contiene 0x000A y DX contiene 0x5F0A, apuntarían a la dirección de memoria: 0x000A * 0x10 + 0x5F0A = 0x5FAA

Para referirse a una dirección con un segmento y un desplazamiento, se utiliza la notación segmento:desplazamiento . En el ejemplo anterior, la dirección lineal 0x5FAA se nombraría como 0x000A:0x5F0A, o si las dos partes se encuentran almacenadas en los registros mencionados, se podría utilizar el par DS:DX. Hay una serie de combinaciones especiales entre registros de segmentos y registros generales que apuntan a direcciones importantes:

  • CS:IP apunta a la siguiente dirección de código en la que se posicionará el procesador.
  • SS:SP apunta al último elemento apilado en la pila.
  • DS:SI se suele usar para apuntar información que va a ser copiada a ES:DI.

Modos de ejecución editar

El procesador soporta numerosos modos de operación para código x86, en los cuales no todas las instrucciones están disponibles. Un sub-repertorio de instrucciones de 16-bit está disponible en “modo real” (disponible en todos los procesadores x86), “modo protegido 16-bit” (disponible desde el Intel 80286), o en el “modo v86” (disponible desde el Intel 80386). Por su parte, las instrucciones de 32-bits están disponibles para el “modo protegido 32-bit” y para el “modo heredado” (disponible con las extensiones de 64 bits). El repertorio de instrucciones parte de ideas similares en cada modo, pero da lugar a distintas formas de acceso a memoria y de este modo emplea estrategias de programación diferentes.

Los modos en los que el código x86 puede ser ejecutado son:

Tipos de instrucciones editar

En general, las características del repertorio de instrucciones x86 son:

  • Una codificación compacta
    • Longitud variable y alineación independiente (codificación en formato little endian)
    • Instrucciones de una y dos direcciones, en las que el primer operando es también el destino.
    • Operandos de memoria como origen y destino compatibles (normalmente utilizados para leer/escribir elementos de la pila usando pequeños desplazamientos inmediatos)
    • Uso de los registros generales e implícitos; a pesar de que los siete registros generales (contando 'ebp') pueden ser utilizados como acumuladores o para direccionar, la mayoría de ellos son también usados implícitamente por algunas instrucciones especiales; los registros afectados deben conservar temporalmente la información, si están siendo utilizados durante el uso de estas instrucciones (normalmente mediante el uso de la pila).
  • Produce flags condicionales implícitamente mediante el uso de la mayoría de instrucciones de la ALU
  • Soporta varios modos de direccionamiento
  • Incluye punto flotante en una pila de registros
  • Contiene soporte especial para instrucciones atómicas (XCHG, CMPXCHG(8B), XADD e instrucciones enteras combinadas con el prefijo LOCK)
  • Instrucciones SIMD (instrucciones que aplican una misma operación sobre un conjunto más o menos grande de datos)

Instrucciones de pila editar

La pila es un segmento que es de suma utilidad en estos microprocesadores. En él se almacenan valores temporales como las variables locales de las funciones, o las direcciones de retorno de estas. 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.

Cuando arranca el programa, SP apunta al final del segmento de pila. Para almacenar información en la pila se decrementa SP para que apunte un poco más arriba y se copia 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.

La instrucción ret size se utiliza para recuperar de la pila los valores de IP o de CS e IP dependiendo del caso. Al salir de un procedimiento es necesario dejar la pila como estaba; para ello podemos utilizar la instrucción pop, o bien ejecutar la instrucción ret size donde size es el número de posiciones que deben descartarse de la pila.

Instrucciones de la ALU con enteros editar

El ensamblador x86 tiene las operaciones matemáticas estándar, como add, sub, mul, y idiv; los operadores lógicos and, or, xor, neg; desplazamientos, sal/sar, shl/shr; rotación con/sin acarreo, rcl/rcr, rol/ror, un complemento de instrucciones aritméticas BCD, aaa, aad, daa y otras.

Instrucciones en coma flotante editar

El ensamblador x86 incluye instrucciones para pila basada en unidades en coma flotante. Entre ellas se encuentran la suma, resta, negación, multiplicación, división, resto, raíces cuadradas, truncamiento entero y truncamiento fraccionado. Las operaciones también incluyen instrucciones de conversión con las que se puede cargar o almacenar un valor desde memoria a cualquiera de los siguientes formatos: BCD, entero de 32 bits, entero de 64 bits, punto flotante de 32 bits, punto flotante de 64 bits u 80 bits. El x86 también incluye funciones como seno, coseno, tangente, arco tangente, exponente con base 2 y logaritmos de base 2, 10 o e.

La conversión de instrucciones al formato del registro de pila es normalmente F (OP) st, st(*) o F (OP) st(*), st, donde st es equivalente a st(0), y st(*) es uno de los 8 registros de pila (st(0), st(1), ..., st(7)). Como con los enteros, el primer operando actúa como primera fuente y como operando destino. La suma, resta, multiplicación, división, almacenamiento y comparación de instrucciones incluye modos de instrucción que se encargan de desapilar una vez completada la operación.

En el caso de que no exista ningún operando, supone destino = ST(1), fuente = ST y se hace además pop sobre la pila, de modo que el resultado se sitúa en lo alto de la pila. Por ejemplo, FADD calcula ST(1)=ST(1)+ST y hace pop sobre la pila (incrementando en uno el puntero de pila), con lo que el nuevo elemento en lo alto de la pila contiene el resultado.

Instrucciones SIMD editar

Los procesadores x86 modernos tienen instrucciones SIMD, que permiten realizar la misma operación en paralelo sobre diversos valores codificados en un registro SIMD. Varias tecnologías de instrucciones soportan diferentes operaciones sobre distintos repertorios de registros, pero todos (desde MMX hasta SSE4,2) incluyen cálculo general sobre aritmética entera o en coma flotante (suma, resta, multiplicación, desplazamiento, minimización, maximización, comparación, división o raíz cuadrada). Por ejemplo, PADDW MM0, MM1 aplica 4 sumas paralelas de enteros de 16 bits (debido a la W que indica que son palabras) de los valores de mm0 hasta mm1, y los almacena en mm0. SSE también incluye el modo en coma flotante en el que el primer valor de los registros está modificado (expandido en el SSE2).

Instrucciones de manipulación de datos editar

El procesador x86 también incluye modos de direccionamiento complejo para direccionar memoria con un desplazamiento inmediato, un registro, un registro con desplazamiento, un registro escalado con o sin desplazamiento y un registro con desplazamiento opcional y otro registro escalado. Entonces por ejemplo, uno puede codificar mov eax, [Table + ebx + esi*4] como una instrucción simple que carga 32 bits de datos desde la dirección localizada en el desplazamiento (Table + ebx + esi * 4) desde el segmento DS, y almacenarla en el registro eax. En general, los procesadores x86 pueden cargar y usar memoria ajustada al tamaño del cualquier registro sobre el que está operando.

Los repertorios de instrucciones x86 incluyen instrucciones de carga, almacenamiento y movimiento de cadenas (LODS, STOS and MOVS) que representan cada operación con un tamaño especificado (B para bytes, W para palabras de 16-bits, D para dobles palabras de 32 bits) e incrementan/decrementan el registro de dirección implícito (SI para LODS, DI para STOS y ambos para MOVS). Para la carga y almacenamiento, el registro destino/fuente implícito es el AL, AX o EAX, dependiendo del tamaño. El segmento usado implícitamente es DS para LODS, ES para STOS y ambos para MOVS.

La pila está implementada con un puntero que disminuye (push) y aumenta (pop) implícitamente. En el modo de 16 bits, este puntero se corresponde a la dirección SS:[SP], en 32- bits sería SS:[ESP] y en 64-bits [RSP]. El puntero de pila se encarga de apuntar al último valor almacenado, asumiendo que su tamaño coincide con el modo del procesador (12, 32 o 64 bits) para que coincida con el ancho por defecto de las instrucciones PUSH/POP/CALL/RET. Otras instrucciones para manipular la pila son PUSHF y POPF, que se utilizan para almacenar y recuperar el registro de FLAGS, almacenándolo o retirándolo de la parte alta de la pila.

Flujo del programa editar

El ensamblador x86 tiene una operación de salto incondicional, jmp, que admite una dirección inmediata, un registro o una dirección indirecta mediante registro como parámetro.

También se permiten los saltos condicionales, como je (saltar cuando hay igualdad), jne (saltar en desigualdad), jg (saltar si 'mayor que', con signo), jl (saltar si 'menor que', con signo), ja (saltar si 'superior/mayor que', sin signo), jb (saltar si 'inferior/menor que', sin signo). Estas operaciones condicionales se basan en el estado específico de los bits del registro de FLAGS. La mayoría de las operaciones aritméticas se encargan de actualizar estos flags según su resultado. Las instrucciones de comparación cmp y test modifican el estado del registro de flags sin modificar los operandos. Las comparaciones en coma flotante se realizan mediante las instrucciones FCOM o FICOM.

Cada operación de salto tiene tres formas diferentes, dependiendo del tamaño del operando. Un salto sort usa un operando con signo de 8bits, que se corresponde con el desplazamiento relativo a la instrucción actual. El salto near es similar al corto pero usa un operando de 16 o 32 bits con signo. Un salto far utiliza el segmento entero base:desplazamiento como una dirección total. También hay forma indirecta e indexada para cada uno de ellos.

Además de las operaciones de salto, existen las instrucciones call y ret para llamar y regresar de una subrutina. Antes de transferir el control a la subrutina, call apila el desplazamiento de la instrucción siguiente a la llamada en la pila; ret desapila este valor de la pila, y transfiere el control a la dirección que indicaba el valor desapilado. En el caso de que se trate de una llamada a una función lejana, far call, la base también se apila siguiendo al desplazamiento.

Existen algunas instrucciones similares, como la interrupción int. Ésta activa el procedimiento de interrupción especificado por el operando y guarda el valor del registro de flags en la pila . La activación de la interrupción se realiza mediante una llamada a un procedimiento lejano (far call), pero en lugar de una dirección, utiliza ese vector de interrupción. La dirección de este vector se calcula multiplicando por 4 el operando, que es un valor entre 0 y 255. Normalmente, el manejador de interrupciones guarda todos los registros que el procesador está usando, a menos que estén siendo usados para almacenar el resultado de una operación. Por otro lado, para volver al programa desde una interrupción se utiliza iret, que se encarga de restablecer el valor de los flags. Las interrupciones ligeras descritas anteriormente son usadas por algunos sistemas operativos para las llamadas del sistema, y pueden también ser usadas para depurar los manejadores de interrupciones fuertes. Estas son provocadas por eventos hardware externos, y deben mantener los valores de todos los registros ya que el estado del programa en ejecución se desconoce. En el Modo Protegido, las interrupciones pueden ser activadas por el Sistema Operativo para realizar un cambio de tarea, almacenando automáticamente todos los registros de la tarea en ejecución.

Véase también editar

Ensambladores editar

Desensambladores editar

Depuradores editar

Microprocesadores editar

Antecesores (las raíces de la arquitectura x86):

  • 1971 Datapoint 2200. Terminal de computadora programable. Su conjunto de instrucciones es la base de los procesadores Intel desde el 8008 al 8085, los cuales a su vez son los antecesores de la arquitectura x86
  • 1972 Intel 8008
  • 1974 Intel 8080
  • 1977 Intel 8085

Algunos microprocesadores de la Arquitectura x86:

Referencias editar

  1. a b Ram Narayam (17 de octubre de 2007). «Linux assemblers: A comparison of GAS and NASM». Archivado desde el original el 14 de junio de 2008. Consultado el 2 de julio de 2008. 
  2. Randall Hyde. «Which Assembler is the Best?». Archivado desde el original el 15 de mayo de 2008. Consultado el 18 de mayo de 2008. 
  3. «GNU Assembler News, v2.1 supports Intel syntax». 4 de abril de 2008. Consultado el 2 de julio de 2008. 

Seguir leyendo editar

Enlaces externos editar