Interfaz entre Assembler y gcc

  1. Alpertron
  2. Microprocesadores de la línea Intel
  3. Application Binary Interface (ABI)

por Dario Alejandro Alpern

Transcripción

Hola. Mi nombre es Darío Alpern y hoy vamos a ver Application Binary Interface.

Aplication Binary Interface

El Application Binary Interface (ABI) es la interfaz entre dos módulos en binario, o sea ya compilados.

En nuestro caso nos vamos a centrar en cómo escribir programas en Assembler de procesadores Intel x86 para que se puedan utilizar con módulos escritos con C y compilados usando gcc.

Hay que tener en cuenta que la interfaz es diferente para otros compiladores, tales como Visual Studio y para otros procesadores.

Lo que vamos a ver en este video es cómo se pasan los parámetros a una función, cómo se usan variables locales y variables globales. También vamos a ver algunos ejemplos.

Uso de la pila

En este esquema se puede ver que la pila se divide en marcos ("stack frame" en inglés), uno por cada función que se anide. Tanto los argumentos de las funciones como las variables locales residen en la pila.

En ambos diagramas, las direcciones más altas de la pila están arriba, así que a medida que ingresan nuevos datos a la pila, el puntero de pila se mueve hacia abajo. Por eso el marco más nuevo se encuentra abajo.

Aunque en el diagrama de la derecha todos los marcos tienen el mismo tamaño, en realidad no es así porque su tamaño depende de la cantidad de parámetros de la función y la cantidad de bytes asignados para variables locales.

El ABI requiere que se use el registro EBP para apuntar al marco correspondiente a la función que se está ejecutando.

El registro ESP siempre apunta a la dirección más baja del marco. Esto permite que no se sobreescriban los datos que están allí si ocurre alguna interrupción, ya que el procesador usa la pila para almacenar el registro EFLAGS y la dirección lógica de retorno.

El registro EBP apunta al registro EBP correspondiente al marco anterior, es decir, el de la función que llamó al que está corriendo ahora.

Así se enlazan los marcos, como en una lista. Eso permite mostrar la pila de llamadas ("call stack" en inglés) en un debugger. La pila de llamadas muestra los nombres de las funciones anidadas y sus parámetros.

En el diagrama de la izquierda se muestra el contenido de cada marco.

Primero la función llamadora coloca los argumentos en la pila, ordenados de derecha a izquierda. De esta manera el último argumento que ingresa es el primero (el de la izquierda) y por eso es el que figura más abajo.

Luego la función llamadora ejecuta una instrucción CALL para llamar a la función actual. De esta manera, el procesador pone en la pila la dirección efectiva correspondiente a la siguiente instrucción luego de ese CALL.

La función actual comienza salvando el registro EBP, ya que lo tiene que usar para apuntar a este marco y hasta ese momento apuntaba al marco anterior. Eso se logra mediante una instrucción PUSH EBP que pone en la pila el viejo valor de EBP.

A continuación la función actual hace que EBP apunte al marco actual mediante la instrucción MOV EBP,ESP y a partir de ese momento los argumentos se pueden acceder mediante direccionamiento indirecto usando EBP como se muestra en el extremo izquierdo.

Finalmente la función actual reserva el área de variables locales restando al puntero de pila ESP la longitud de esa área.

Partes de una función

En base a lo que vimos recién, podemos dividir el código de una función en tres partes: prólogo, cuerpo y epílogo.

El prólogo es el encargado de terminar de armar el marco de pila con las instrucciones que vimos recién.

El cuerpo es el conjunto de instrucciones que realizan lo que debe hacer la función, utilizando entre otros recursos el marco de pila.

El epílogo es el encargado de destruir el marco de pila haciendo que EBP vuelva a apuntar al marco anterior.

La primera instrucción MOV ESP,EBP libera la zona de variables locales. La segunda instrucción POP EBP hace que el registro EBP apunte al marco de la función llamadora y finalmente la instrucción RET vuelve a la función llamadora.

Cuando termina el epílogo, la pila no queda balanceada, así que la función llamadora se debe encargar de sumar al puntero de pila ESP la cantidad de bytes correspondiente a los parámetros.

Si no hay variables locales, la última instrucción del prólogo y la primera del epílogo no hacen falta.

Llamada a función de C en Assembler

Como comentamos anteriormente, lo primero que debe hacer el código para llamar a una función, es poner los parámetros en la pila.

Si los tipos de datos de los parámetros tienen 1 byte (char), 2 bytes (short) o 4 bytes (int, long, float o puntero a cualquier tipo), se ponen 4 bytes en la pila. Si tienen 8 bytes (long long o double) se ponen los 8 bytes en la pila.

En el ejemplo se puede observar una llamada a función que tiene dos argumentos. El orden de las instrucciones PUSH es de derecha a izquierda, luego llamamos a la función y finalmente liberamos el espacio ocupado por ambos argumentos, que como ocupan 4 bytes cada uno, debemos sumar 8 al puntero de pila ESP.

Valor de retorno

En caso que el código en C llame a nuestra rutina en Assembler, debemos saber en qué registros debe ubicarse el valor de retorno.

Si la función retorna un byte (char), el resultado debe ubicarse en el registro AL.

Si la función retorna dos bytes (short), el resultado debe ubicarse en el registro AX.

Si la función retorna cuatro bytes como entero o puntero, el resultado debe ubicarse en el registro EAX.

Si la función retorna 8 bytes como entero, deberá ubicarse la parte alta del resultado en el registro EDX y la parte baja en EAX.

Si la instrucción retorna el tipo float o double, el resultado debe ubicarse en el primer elemento de la pila de la unidad de punto flotante ST(0).

Ejemplo 1

Aquí se puede ver cómo se compila en Assembler la función en C que se muestra a la izquierda.

Como no hay variables locales, se puede ver que se usa la versión resumida de dos instrucciones del prólogo y del epílogo.

Ahora veremos el cuerpo de la función.

El segundo parámetro, seg, tiene la dirección efectiva EBP+12. Por lo tanto la instrucción seg++; se traduce a INC DWORD [EBP+12]. La cláusula DWORD indica cuatro bytes, que es el tamaño del tipo int.

La segunda instrucción, return pri + seg * 7 debe colocar el resultado del cálculo en el registro EAX, ya que ahí debe ir el valor de retorno porque tiene tipo int.

Primero cargamos en el registro EAX el valor de seg, que como dijimos, se encuentra en la dirección efectiva EBP+12 y luego lo multiplicamos por 7.

El resultado de la multiplicación se encuentra en los registros EDX la parte alta y EAX la parte baja. Como en el estándar C sólo se utiliza la parte baja de la multiplicación, el valor del registro EDX no nos interesa. Al producto que quedó en EAX, le sumamos el valor del argumento pri, que se encuentra en la dirección efectiva EBP+8. De esa manera, queda el valor de retorno en el registro EAX.

Conversión de tipos de datos enteros

En C la conversión de tipos de datos es muy habitual. Por ejemplo, si quiero sumar una variable de tipo char a una variable de tipo int, existe una conversión automática de la primera variable a int para poder realizar la suma, ya que ambos operandos de la suma deben tener el mismo tamaño.

Cuando se hace una conversión de tipos de enteros, existen dos posibilidades dependiendo de si el tipo más pequeño es signado o no. En el primer caso, para expandir la variable, se utiliza el bit más significativo que es el de signo.

En el primer ejemplo, el short 0xA0A0 tiene el bit más significativo a uno (signo negativo), por lo que completamos con unos a la izquierda y el resultado es 0xFFFFA0A0.

En el segundo ejemplo, el short 0x2020 tiene el bit más significativo a cero (signo positivo), por lo que completamos con ceros a la izquierda y el resultado es 0x00002020.

Si el tipo más pequeño no es signado, siempre se completa con ceros.

Por ejemplo, unsigned short 0xA0A0 se convierte a int agregando ceros a la izquierda y el resultado es 0x0000A0A0.

Instrucciones MOVZX y MOVSX

El procesador tiene dos instrucciones para convertir rápidamente los tipos de datos: MOVZX (move with zero extension) y MOVSX (move with sign extension).

MOVZX se usa cuando el tipo pequeño es no signado y lo que hace es completar el destino con ceros a la izquierda.

MOVSX se usa cuando el tipo pequeño es signado y completa el destino con el bit de signo del operando fuente.

Se pueden ver dos ejemplos de conversión de datos signados y no signados.

En el primer ejemplo queremos convertir un signed char que se encuentra en BL al tipo int almacenándolo en el registro EAX. Ejecutamos la instrucción MOVSX EAX,BL.

En el segundo ejemplo convertimos un unsigned short que se encuentra en el primer argumento al tipo int almacenándolo en el registro ECX. La instrucción a ejecutar es MOVZX ECX,WORD [EBP+8]. La cláusula WORD es necesaria porque podríamos convertir un byte también.

Instrucción LEA

La instrucción LEA se usa para cargar punteros, generalmente a la pila, es decir argumentos y variables locales.

Esta instrucción tiene dos operandos. El primero, que es el destino, es un registro, y el segundo que es el fuente, es un direccionamiento indirecto a memoria, y por eso lleva corchetes.

Sin embargo, la instrucción no lee memoria, sino que sólo calcula el puntero y lo carga en el registro destino. El procesador no valida el puntero, así que no se genera excepción 13 o 14 si el puntero es inválido.

Se pueden ver dos ejemplos:

En el primer ejemplo obtenemos un puntero a una variable local mediante LEA ECX,[EBP-4]. El procesador calcula EBP-4 y pone el resultado de la resta en el registro ECX.

En el segundo ejemplo obtenemos el puntero a un elemento de un array global de enteros, que comienza en la dirección efectiva ESI+0x224 y el índice se encuentra en el registro EDI. El procesador calcula ESI + 4*EDI + 0x224 y almacena el resultado de este cálculo en el registro EDX.

Ejemplo 2

En este ejemplo se puede ver una variable local y una conversión de datos de char a int, porque la segunda instrucción de la función tiene una resta de dos tipos diferentes, y el compilador genera una conversión de datos automática.

Se puede ver en rojo el prólogo y en verde el epílogo. La cantidad de bytes de variables locales es de 4, porque tenemos una variable de tipo int. Por eso reservamos el área de variables locales mediante la instrucción SUB ESP,4 en el prólogo.

Ahora vamos a analizar el cuerpo de la función, que tiene las instrucciones marcadas en azul.

La primera instrucción ejecutable en C es varlocal = k - m; donde el argumento k tiene tipo int y el argumento m tiene tipo char. Por lo tanto debemos utilizar una instrucción de conversión de datos. Como el tipo más pequeño es signado, la instrucción es MOVSX.

El argumento m es el segundo, así que su dirección efectiva es EBP+12. Por lo tanto, leemos el valor de m mediante la instrucción MOVSX EBX,BYTE [EBP+12].

Luego leemos el primer parámetro k mediante la instrucción MOV EAX,[EBP+8] y restamos ambos parámetros mediante SUB EAX,EBX. El resultado debe colocarse en la variable local varlocal mediante la instrucción MOV [EBP-4],EAX ya que varlocal se encuentra en la dirección efectiva EBP-4.

Para la instrucción return varlocal * k; multiplicamos el valor recién hallado por el parámetro k dejando la parte baja del producto en el registro EAX.

Comparaciones

En las instrucciones if, while y for, normalmente se comparan dos operandos y se salta si es igual, mayor, menor, etc. Ambos operandos deben ser signados o no signados. En caso contrario el estándar C no define cuál es el resultado, y el compilador señala una advertencia (warning en inglés).

Para compilar la comparación, primero se genera una instrucción CMP para comparar ambos operandos y luego se genera la instrucción de salto condicional dependiendo del tipo de los operandos, signados o no, y de las seis clases de comparaciones, por igual, mayor, menor, distinto, mayor o igual o menor o igual.

Ejemplo 3

En este ejemplo vemos punteros, arrays y comparaciones.

La función tiene un array de 6 enteros, un short y un int como variables locales. En el prólogo se puede ver que se reservan 6*4 bytes para el array, 4 para el short y 4 para el int.

Esto es así porque las variables de tipo int se ubican en las direcciones efectivas múltiplo de 4. Así que hay dos bytes en el área de variables locales que no se utilizan.

La primera instrucción ejecutable es ptrArr = arr; que se resuelve mediante dos instrucciones de Assembler. Primero obtenemos el puntero al array arr mediante la instrucción LEA EAX,[EBP-24] y luego cargamos ese puntero en la variable local correspondiente mediante la instrucción MOV [EBP-32],EAX.

Luego implementamos el ciclo for.

La primera parte del ciclo for, que es m=0, siendo la variable local m de tipo short, es decir 16 bits, se efectúa mediante la instrucción MOV WORD [EBP-28],0.

A continuación ponemos la etiqueta ciclo, que indica donde comienza el cuerpo del for.

Dentro del ciclo for, debemos compilar la instrucción arr[m] = m; Hay que tener en cuenta que el array local arr es de tipo int y la variable local m es de tipo short, así que hay una conversión automática para poder realizar la asignación.

Lo primero que debemos hacer es convertir a int la variable local m, y eso se logra mediante la instrucción MOVSX ECX,WORD [EBP-28].

Para completar la asignación, debemos escribir el registro ECX en el elemento indicado del array.

El array comienza en la dirección efectiva EBP-24 y el elemento a escribir, que es m, ya está en el registro ECX. Así que la instrucción de Assembler es: MOV [EBP-24 + ECX*4],ECX.

A continuación debemos cerrar el ciclo for.

Incrementamos la variable local m mediante la instrucción INC WORD [EBP-28] y luego lo comparamos contra el valor final 6 mediante CMP WORD [EBP-28],6.

Debemos utilizar el salto signado por menor, y por eso usamos la instrucción JL ciclo.

Como la función es void, no necesitamos poner ningún valor en el registro EAX.

Secciones

Lo que vimos hasta ahora es el manejo de parámetros de funciones y variables locales que se encuentran en la pila.

Ahora veremos dónde se ubica el código y variables globales y estáticas.

El programa ejecutable se divide en secciones, que identifican el tipo de información que poseen.

Las secciones más importantes para nosotros son cuatro:

El código ejecutable se encuentra en la sección .text.

Los datos constantes, es decir aquéllos que no varían durante la ejecución del programa, se encuentran en la sección .rodata

Los datos inicializados que se pueden modificar se almacenan en la sección .data

Finalmente, los datos no inicializados se almacenan en la sección .bss. La rutina de C que corre antes de la función main inicializa esta sección a cero.

Ejemplo 4

En este ejemplo se ven variables globales y estáticas.

Las variables y arrays que no tienen la cláusula static se pueden ver desde otros módulos de C, así que cuando se genera código en Assembler, las variables globales deben estar incluidas en la directiva global. Las variables estáticas no se deben usar con la directiva global.

Las variables declaradas con extern en lenguaje C están definidas en otro módulo y cuando se genera código en Assembler, debemos incluir estas variables en la directiva extern.

En el ejemplo vemos cuatro variables, tres definidas en este módulo y otra definido en un módulo externo.

Los arrays arr y str son globales, así que en Assembler debemos escribir las directivas global arr y global str.

La variable ctr es estática así que no corresponde directiva global o extern en Assembler.

La variable value está definida en otro módulo y la declaramos en Assembler mediante la directiva extern value.

El array definido mediante int arr[100]; no está inicializado, así que declaramos la sección .bss mediante la directiva section .bss y reservamos espacio para 100 enteros usando arr resd 100.

El array char str[] = "Hola mundo"; está inicializado, así que lo definimos en la sección .data. Entonces, usamos la directiva section .data y luego declaramos el array de caracteres mediante str db "Hola mundo", 0. El cero es el terminador de la cadena de caracteres.

La variable estática ctr declarada mediante static int ctr = 3; también está inicializada y debe estar en la sección .data. Así que no hace falta declarar una nueva directiva section. Luego declaramos la variable de 32 bits mediante ctr dd 3.

Pasaje de parámetros en 64 bits

Si bien en este video hablamos de ABI de gcc en procesadores Intel de 32 bits, acá incluimos cómo se pasan los parámetros en el caso de aplicaciones de 64 bits.

No siempre se pasan los parámetros por la pila. Los seis primeros parámtros de tipo entero pasan por registro: RDI, RSI, RDX, RCX, R8 y R9 en ese orden.

En el caso de argumentos de punto flotante (float o double), se usan registros SIMD para los primeros 8 parámetros: XMM0, XMM1, XMM2, XMM3, XMM4, XMM5, XMM6 y XMM7 en ese orden.

Si hay más parámetros, se pasan por la pila.

Espero que les haya sido de interés. Hasta luego.