miércoles, junio 14, 2006

Mezclando C con Assembly

Anteriormente titulado "Cómo mezclar C con código de ensamblador (assembly)"

Copyright (c) 2006, 2007, 2008, 2009 Héctor Francisco Hernández <hectorfh@gmail.com>.
Se otorga permiso para copiar, distribuir y/o modificar este documento bajo los términos de la Licencia de Documentación Libre de GNU, Versión 1.3 o cualquier otra versión posterior publicada por la Free Software Foundation; sin Secciones Invariantes ni Textos de Cubierta Delantera ni Textos de Cubierta Trasera. Una copia de la licencia se encuentra en http://www.gnu.org/copyleft/fdl.html.

1. Introducción.

Existe una relación muy estrecha entre la programación en C y la programación en ensamblador. C fue creado para evitarle a los programadores del sistema operativo UNIX seguir escribiéndolo en ensamblador. Las ventajas de programar en C lo mismo que antes se programaba en ensamblador son obvias: portabilidad y legibilidad a través de un código bien estructurado. Sin embargo las partes del sistema en las que es necesario trabajar con características específicas del hardware no pueden ser escritas en C.

En general hay dos estrategias para mezclar código en C con código en ensamblador. La primera consiste en compilar archivos escritos en C y ensamblar otros escritos en código de ensamblador para luego enlazarlos todos juntos. La segunda se trata de embeber código escrito en ensamblador dentro de código escrito en C a través de algún mecanismo que ofrezca el compilador usado. En este artículo se tratará sólo la primera estrategia.

Aunque las ideas que aquí se exponen son útiles independientemente de cualquier plataforma, toda la nota presupone un sistema tipo UNIX (GNU en particular) en una arquitectura de procesador 80386 o superior.

2. Construyendo programas.

Dado un conjunto de archivos en C, es necesario seguir ciertos pasos para obtener uno ejecutable.

Para comenzar se deben incorporar los archivos de cabecera (los que se incluyeron mediante "#include"), expandir las macros y constantes (las que se declararon mediante "#define") y quitar el código que no satisfaga las condiciones para ser compilado (condiciones establecidas mediante "#ifndef", "#ifdef" o alguna otra estructura condicional del preprocesador). Esto en C se conoce como "preprocesar" el código fuente. En GNU/Linux los archivos "preprocesados" poseen la extensión ".i" y se generan con cualquiera de los siguientes comandos:

$ cpp  ejemplo1.c > ejemplo1.i

o

$ cc -E ejemplo1.c > ejemplo1.i

El próximo paso consiste en traducir el código en C preprocesado a código en ensamblador. Esto se hace del siguiente modo:

$ cc -S ejemplo1.i

Sin embargo también es posible obtener el código en ensamblador a partir del archivo "ejemplo1.c":

$ cc -S ejemplo1.c

En este último caso el compilador preprocesará el archivo automáticamente y al terminar la traducción eliminará "ejemplo1.i". De cualquier forma se obtendrá el mismo resultado, el archivo "ejemplo1.s".

El tercer paso será ensamblar para conseguir código de máquina:

$ as -o ejemplo1.o ejemplo1.s

Alternativamente se puede hacer con el comando

$ cc -c ejemplo1.s

Así el compilador de C llamará al ensamblador automáticamente.

Como resultado deberíamos obtener un archivo objeto con extensión ".o". Los archivos objeto contienen código de máquina en el que las direcciones de memoria no han sido definidas aún. Hay distintos formatos para estos archivos, entre ellos ELF (de Linux), COFF (algunos sistemas tipo UNIX), OMF (algunos sistemas de microchot).

Llegado a este punto debo decir que se podría haber obtenido el archivo "ejemplo1.o" directamente de "ejemplo1.c" de la siguiente forma:

$ cc -c ejemplo1.c

Ahora sólo resta enlazar todos los archivos objeto para obtener el archivo ejecutable "programa-ejemplo":

$ cc -o programa-ejemplo ejemplo1.o ejemplo2.o ejemplo3.o

Para hacer esto también es posible llamar al enlazador directamente con el comando "ld". Aunque no recomiendo hacerlo ya que es necesario pasarle demasiados parámetros. Para ver un ejemplo visite el enlace sugerido al final del artículo al tutorial de gcc de Víctor Alberto González Barbone.

La compilación separada.

Note que podríamos haber compilado todo junto con

$ cc -o programa-ejemplo ejemplo1.c ejemplo2.c ejemplo3.c

Sin embargo compilar todo en un único paso no es una buena opción. Cada vez que cambiaremos algunos de los archivos habrá que volver a compilar todos nuevamente, y eso llevará tiempo. Además, si ocurriere un error la compilación se suspenderá perdiendo los resultados intermedios.

Para manejar la compilación de varios archivos por separado la herramienta adecuada es el "make". Explicar cómo utilizarlo excede los límites de este artículo. Nuevamente sugiero visitar los enlaces al final para más información.

3. Mezclando código en lenguaje C con código en ensamblador.

Un ejemplo introductorio.

Comenzaré mostrando un inútil programa que recibe como parámetro un número real y lo redondea. Aunque no entienda muy bien el código, no se preocupe, lo importante ahora es que comprenda la estructura.

Se trata de tres archivos: "main.c", "redon.s" y "redon.h". El primero es el que contiene la función "main" escrita en C, ahí es donde comienza la ejecución. El segundo contiene la función "redon", que recibe como parámetro un número flotante, lo redondea y lo convierte en entero. En el tercero está el prototipo de "redon", necesario para que la función pueda ser llamada desde otros archivos.

El archivo "main.c":

#include "redon.h"
#include <stdio.h>
#include <stdlib.h>

int
main (int argc, char *argv [])
{
  float n;
  int n_redon;

  if (2 != argc) {
    fprintf(stderr, "Error: debe haber un parámetro\n");
    return 1;
  }

  n = (float) atof(argv [1]);
  n_redon = redon(n);

  printf("El número entero que más se aproxima a %f es %d.\n",
      n, n_redon);

  return 0;
}

El archivo "redon.s":

.text

.globl redon
redon:
  flds        0x04 (%esp)
  fistpl      0x04 (%esp)
  movl        0x04 (%esp), %eax
  ret

El archivo "redon.h":

#ifndef REDON_H
#define REDON_H

int redon (float);

#endif /* REDON_H */

Observe el uso de las directivas ".text" y ".globl". La primera, aunque no parezca, indica que lo que sigue es código. La segunda, que "redon" podrá ser llamada desde funciones que estén en otros archivos objeto. Esto contrasta con las funciones en C, las cuales son globales por defecto a no ser que se indique lo contrario mediante la palabra clave "static".

Para compilar y enlazar todo se deben ejecutar los siguientes comandos:

$ cc -Wall -O -c main.c
$ cc -c redon.s
$ cc -o redondear main.o redon.o

Las tres convenciones.

Para escribir funciones en ensamblador que puedan ser llamadas desde otras en C debe conocer tres convenciones. La primera tiene que ver con el pasaje de parámetros, la segunda con el retorno de valores y la tercera con la preservación de los valores en los registros.

i. Pasaje de parámetros

Cuando un programa se ejecuta el sistema operativo le asigna memoria para que pueda operar. Parte de la memoria asignada al programa es utilizada como una pila para almacenar las variables locales a las funciones (a excepción de las declaradas con la palabra clave "static"), los parámetros y algunas cosas más.

En una arquitectura 80386 el registro "esp" (puntero de pila) es el que se encarga de guardar un puntero hacia el tope de la pila. Cuando se insertan datos en la pila a través de la instrucción "push" el tope de la pila ya no es el mismo, entonces el puntero de pila (que está guardado en el registro "esp") es cambiado automáticamente para que pueda continuar apuntando hacia el tope. Lo inverso ocurre cuando se quitan datos de la pila a través de la instrucción "pop". Cabe recordar que en una pila no es posible insertar o quitar datos de otro lugar que no sea su tope, de lo contrario no sería una pila.

Si no está familiarizado con lo que estoy diciendo, probablemente tampoco esté familiarizado con la programación en ensamblador. Entonces sería muy conveniente que lea algún apunte introductorio acerca del tema. En Internet debe haber alguno corto. La programación en ensamblador excede los límites de este humilde artículo.

Volviendo al punto, la importancia de la pila aquí es que es el mecanismo mediante el cual se pasan los parámetros a las funciones. Los parámetros son pasados de derecha a izquierda, es decir, primero se inserta en la pila el último parámetro, luego el penúltimo, y así se continúa hasta insertar finalmente el primero. La función que llama a otra es la que se encarga de colocar los parámetros en la pila y también de quitarlos. Esta es la convención en C, aunque existen otras convenciones en otros lenguajes en las cuales es la función llamada la que quita los parámetros de la pila.

En un intento de aclarar el tema diré que para invocar a la función

void f (int param1, int param2, int param3);

con param1 = 3, param2 = 2 y param3 = 5 se debería escribir el siguiente código en C:

f (3, 2, 5);

y el siguiente en ensamblador:

pushl   $5
pushl   $2
pushl   $3
call    f
addl    $12, %esp

Las tres primeras instrucciones colocan los parámetros en la pila. La cuarta llama a la función "f" y la quinta le adiciona doce al puntero de pila. Esto es porque los parámetros insertados ocupan doce bytes entre los tres (cuatro cada uno).

Adicionarle y sustraerle al puntero de pila son maneras de liberar espacio y de hacer espacio en la pila respectivamente.

Notará que luego de "push" y de "add" hay una "l". Esto no es propio del lenguaje ensamblador 80x86 de Intel. Sino que pertenece a la notación de AT&T, que es la adoptada por el ensamblador de GNU. Esta "l" significa que el parámetro de la instrucción es de 32 bits.

ii. Retorno de valores.

Según el tipo del valor retornado se procede de distinto modo.

Para los tipos de datos predefinidos por el lenguaje la función llamada deposita el valor retornado en determinado registro. Luego la función que ha realizado el llamado asumirá que está en el registro convenido y accederá a él para obtenerlo. El registro que se utiliza depende del tipo de dato del valor retornado.

A continuación listaré distintos tipos de datos y los registros utilizados:

int / unsigned int / long / unsigned long -> eax
short / unsigned short -> ax
char / unsigned char -> al
punteros -> eax
float / double -> st (0)

Las funciones que devuelven un tipo de dato definido por el usuario mediante "struct" o "union" cuentan automáticamente con un primer parámetro implícito adicional que indica la dirección de memoria en donde será copiado el valor devuelto.

Por lo tanto se podría decir que las funciones:

struct s f (int param1, int param2, int param3);

y

void f (struct s *retval, int param1, int param2, int param3);

una vez traducidas al ensamblador tienen los mismos parámetros.

Los tipos de datos definidos con "enum" se implementan del mismo modo que un numero entero, por lo tanto se devuelven copiando el valor al registro "eax".

iii. Preservación de los valores de los registros.

En el momento de retorno de una función se espera que el valor de ciertos registros sea el mismo que en el momento de su llamado. Estos registros son ebp, esp, ebx, esi y edi. Por lo tanto si la función hará uso de ellos deberá guardarlos previamente y restaurarlos a sus valores originales luego, o asegurarse de cualquier otra forma que queden como los puso la función que hizo el llamado.

Los registros eax, ecx, edx, eflags y los de la unidad de punto flotante (st (0), st (1), st (2), ..., st (7) y los registros de control y estado) pueden ser utilizados sin cuidado alguno. El resto de los registros del procesador (que no enumeraré) sólo puede ser modificado por el sistema operativo, por lo tanto se descarta su posibilidad de uso.

4. Algunos consejos.

Llegando ya al final me gustaría mencionar algunas sugerencias.

i. El compilador de C es una buena herramienta para estudiar ensamblador.

Escribir funciones en C y utilizar el compilador para traducirlas al ensamblador es una excelente manera de conseguir ejemplos de rutinas en este lenguaje. Debería ser uno de los primeros recursos para eliminar todas las dudas que surjan.

Es aconsejable hacerlo activando la opción de optimización para obtener un buen código y las advertencias para detectar errores. El comando para hacer esto es el siguiente:

$ cc -Wall -O -S ejemplo.c

ii. Cuidado con el "decorado de nombres" en C++ y en algunos sistemas operativos.

Es muy probable que algunos, tras leer este artículo, intenten mezclar ensamblador con C++ y las cosas no funcionen. Este lenguaje posee una propiedad denominada "decorado de nombre" (name decoration) o "planchado de nombre" (name mangling) que consiste en cambiar el nombre de las funciones cuando son traducidas al ensamblador. C++ necesita hacer esto para soportar la sobrecarga de funciones, las funciones miembro y otras características del lenguaje. Por lo tanto esperará que todos los nombres hayan sido decorados adecuadamente.

A pesar de esto es posible invocar funciones cuyo nombre no ha sido "decorado" encerrando sus prototipos en un bloque precedido por la frase "extern "C" ".

Para ilustrar con un ejemplo, el archivo "redon.h", para poder ser usado en C y también en C++, debería escribirse así:

#ifndef REDON_H
#define REDON_H

#ifdef __cplusplus
extern "C" {
#endif

int redon (float);

#ifdef __cplusplus
}
#endif

#endif /* REDON_H */

El compilador de C en un popular sistema operativo no POSIX propenso a fallas realiza también un decorado según la convención de llamada de la función. Sin embargo consiste sólo en agregarle un guión bajo al nombre. Así, por ejemplo, el archivo "redon.s" debería sustituirse por el siguiente:

.text

.globl _redon
_redon:
   flds        0x04 (%esp)
   fistpl      0x04 (%esp)
   movl        0x04 (%esp), %eax
   ret

Pero "redon.h" no debería ser modificado.

Para una mejor explicación del asunto se recomienda visitar los enlaces al final del artículo.

iii. Utilice el comando "objdump" para desensamblar archivos binarios.

Este comando propio del UNIX desensambla archivos objeto y archivos ejecutables.

Para hacerlo debe invocar el comando con la opción "-d":

$ objdump -d ejemplo.o

Consulte la documentación del sistema operativo para ver otras opciones que acepta el comando.

iv. Utilice el comando "nm" para listar las funciones en un archivo binario.

En los sistemas tipo UNIX el comando "nm" lista las funciones y variables que están dentro de un archivo objeto o un archivo ejecutable y a las que hacen referencia funciones dentro del archivo.

Puede utilizarlo del siguiente modo:

$ nm ejemplo.o

Para aprender a leer el listado que genera sugiero recurrir a la documentación del comando.

5. Enlaces que se sugiere visitar.

  1. Breve tutorial en español acerca del gcc de Víctor Alberto González Barbone.
  2. Artículo de la Wikipedia en inglés que explica con detalle de qué se trata el planchado de nombres.
  3. Artículo en inglés de "vivek" que introduce la sintaxis de AT&T.