pDI-Tools: Mecanismo de interposición dinámica de código | ||
---|---|---|
Anterior | Capítulo 3. Portabilidad e implementación en las diferentes plataformas | Siguiente |
En esta sección se explica que decisiones se han tomado para maximizar la portabilidad del código fuente de pDI-Tools.
Se trata desde el dialecto de C usado hasta las funciones de librería usadas. Esto delimita el tipo construcciones de C usadas, el uso del preprocesador, los tipos de datos disponibles y otros detalles.
Se explica el impacto que han tenido estas decisiones y consideraciones en el código, y como se ha hecho para se mantenga simple y mantenible.
Por último se explica la estrategia usada para incorporar información dependiente de la arquitectura de forma que sea transparente y usable por la parte portable.
Para programar pDI-Tools determine usar un C lo más estándar posible. Para ello se ha escogido el dialecto ISO C90 de C. El ISO C90 es un dialecto de C entendido por todos los compiladores actuales y rara vez genera incompatibilidades.
Para verificar que pDI-Tools cumple este requerimiento se realizaban cada
cierto tiempo compilaciones con los "flags" -ansi
y
-pedantic
. Estos "flags" hacen que compilador GNU C Compiler
muestre avisos y errores en caso de usarse construcciones específicas
del compilador y/o llamadas a funciones exclusivas de GNU C Library.
Lo malo es que el ISO C90 en cierto modo es un poco incompleto, y en ocasiones queda a manos del implementador del compilador tomar algunas decisiones. Un ejemplo sería indicar que una determinada función sea el segmento de código de inicialización o finalización de un DSO (secciones .init y .fini). No obstante se procura recurrir a estas características lo mínimo posible.
Todo esto significa a renunciar a muchas extensiones, que aunque muy comunes, nadie nos garantiza que estén presentes en todos los compiladores. Por lo tanto nunca se usan comentarios C++, ni uniones anónimas, ni espacios de nombres (de C++) ni declaración de variables en medio del cuerpo de una función.
El resultado es un código bastante claro, clásico y sencillo en el aspecto de que no explota explícitamente características avanzadas del compilador. Esto no significa que algunas veces se escriba el código con un poco de picardía para poder reconducir un poco la generación del binario y así explotar ciertas optimizaciones comunes entre muchos compiladores modernos.
El preprocesador (cpp(1)), a pesar de ir ligado a un compilador de C, es una aplicación que implementa un lenguaje de macros muy potente. De hecho es muy normal encontrar proyectos en otros lenguajes que hacen uso del preprocesador de C.
El problema es que el preprocesador a veces varía mucho de un compilador a otro. No todos ofrecen la misma potencia, flexibilidades o incluso algunos interpretan de forma diferente algunas estructuras. Por ejemplo GNU C Compiler permite declarar macros con número de parámetros variable, cosa nada habitual en otros compiladores.
El preprocesador, es esencial para establecer buenos entornos de depuración o para crear programas multiplataforma. Es ideal para tomar opciones de configuración en tiempo de compilación, para crear alias a estructuras, tipos y funciones y para activar/desactivar código.
El preprocesador decide que se compila finalmente, y por lo tanto, el código que intervendrá en la generación del ejecutable. Esto nos permite activar y desactivar código sin penalizaciones en el tiempo de ejecución de la versión final. Además nos permite escoger el código que se usará en una plataforma u otra, reduciendo el tamaño del binario final.
No obstante sólo se usan unas pocas características del preprocesador para evitar ligarnos accidentalmente a un compilador determinado:
El carácter # siempre se escribe en la columna cero. Todas las directivas del preprocesador comienzan por el carácter # (#define, #if, #error, ...). En algunos preprocesadores antiguos no se acepta que antes del carácter # hayan espacios. En cambio si que se acepta en todos los preprocesadores que hayan espacios entre el carácter # y la directiva del preprocesador.
Esta limitación deriva del C original de Kernighan y Richie y ha sido mantenida en muchos compiladores hasta hace poco.
Así las directivas se indentan de la siguiente forma:
#if HAVE_STRING_H # include<string.h> #else # if HAVE_STRINGS_H # include<string.h> # endif #endif
No se declaran nunca constantes vacías. Es normal encontrarse construcciones como esta en programas en C:
#define DEBUG void myfunc() { #ifdef DEBUG debug_msg("Se ejecuta la función 'myfunc()'"); #endif /* ... */ }
El problema de usar constantes vacías es que es fácil escribir a veces por accidente #if DEBUG en lugar de #ifdef DEBUG. El resultado depende totalmente de la interpretación del compilador y por lo tanto es difícil saber si se usará el código en el interior del #if.
Por ello toda constante debe tener asignado un valor, y en caso de querer usarse como "flag" de compilación esta debe recibir exactamente el valor 1. Así se interpretará siempre de la misma forma en las condiciones #ifdef y en las condiciones #if.
Se evita el uso de condicionales siempre que se pueda. La mejor manera de usar el preprocesador es de forma que no se note. Si se usan intensivamente las directivas condicionales del preprocesador se llega a un código ilegible, difícil de depurar y comprender.
Un programa portable en muchas ocasiones debe presentar diferentes versiones de código para diferentes arquitecturas. La manera más inmediata de hacerlo es mediante condiciones del preprocesador. Esta manera es adecuada cuando las porciones de código son pequeñas y representan una pequeña parte del total de una función.
El problema aparece cuando estas directivas engloban muchas funciones o trozos de código grande. En estos casos es preferible aislar el código para las distintas plataformas en ficheros diferentes. Así, si se pretende leer el código de una implementación, no se ve código de otras implementaciones que pueda inducir a confusión.
Sólo se usan macros en determinadas ocasiones. Se ha intentado reducir el número de macros al máximo posible, siempre y cuando la legibilidad no se vea afectada por ello.
Las macros se usan principalmente para crear cadenas de texto con el nombre de funciones y para construir los tipos ELF adecuados según la plataforma. Para ello el preprocesador debe implementar dos operadores pertenecientes a la especificación ISO C90: "ISO stringification operator #" y "token paste operator ##".
El primero, "ISO stringification operator #", permite construir una cadena de texto a partir de un "token". De esta forma podemos referirnos a una función y su nombre mediante macros:
#define STRINGIZE(x) #x #define MI_FUNCION hello #define MI_FUNCION_NAME STRINGIZE(hello) int MI_FUNCION() { printf("La función '%s' dice 'hola mundo!'\n", MI_FUNCION_NAME); }
El segundo permite concatenar dos parámetros o "tokens". Esto nos
permite usar unas constantes o macros del preprocesador según la
arquitectura. Por ejemplo todos los tipos ELF llevan el prefijo
Elf32_ o Elf64_ según la
arquitectura objeto sea de 32 o 64 bits. Por ello se usa la macro
ElfW(x)
que escoge por nosotros el tipo
adecuado. La implementación de esta macro hace uso del operador de
concatenación de "tokens":
/* NOTA: __ELF_NATIVE_CLASS puede valer 32 o 64. Depende de la arquitectura */ #ifndef ElfW #define ElfW(type) _ElfW (Elf, __ELF_NATIVE_CLASS, type) #define _ElfW(e,w,t) _ElfW_1 (e, w, _##t) #define _ElfW_1(e,w,t) e##w##t #endif ElfW(Sym) *search_sym(char *sym) { ElfW(Rel) *rel; ElfW(Addr) tmp; /* ... */ }
El resto de macros por regla general suelen ser muy sencillas y se limitan tan sólo a operaciones aritméticas. Se evita usar macros complejas, de gran tamaño, o que representen bloques de código.
Al escribir una aplicación multiplataforma se ha de ser cuidadoso con los tipos de datos, especialmente cuando se apunta tanto a plataformas de 32 como de 64 bits.
El tamaño de los tipos de datos en un modelo de programación y otro a veces varían, y por lo tanto también varía el rango de los números que puede manejar.
Además hay tipos que son exclusivos de un compilador o de un determinado estándar. Por ejemplo ISO C99 aporta el tipo long long int que fuerza a usar un entero de 64 bits.
Usaremos siempre tipos sencillos y fácilmente predecibles en todas las arquitecturas. Por ello nos ceñiremos sólo a tipos, operaciones y asignaciones entre tipos declaradas por el estándar ISO C90.
Sólo se quebrantará esta regla en el caso de operaciones con punteros a funciones, que por características del programa, son inevitables. Por suerte esto es algo soportado por todos los compiladores del mercado.
Respecto a la construcción de estructuras de datos y uniones se será respetuoso al máximo con el estándar. Esto significa que no existirán estructuras o uniones anónimas, ni se usarán extensiones del lenguaje para poder modelarlas mejor.
Los tipos de datos basados en estructuras se crearán usando el operador typedef. De esta forma nos referiremos a las estructuras normalmente por su nombre de tipo y no por el nombre de la estructura. El nombre de la estructura será el nombre del tipo pero con el prefijo _tag_. Por ejemplo:
typedef struct _tag_BECFG_GRAPH_NODE { BECFG_OBJ_INFO *gn_object; int gn_weight; int gn_hasPar; int gn_nto; struct _tag_BECFG_GRAPH_NODE **gn_to; } BECFG_GRAPH_NODE
Por último se usarán, siempre que se pueda, los tipos de datos indicados por el ISO C90 para las funciones de las librerías de C. Así por ejemplo siempre se preferirá usar el tipo ssize_t a int. También se usará siempre que se pueda tipos ELF para evitar hacer conversiones innecesarias.
Los datos dependientes de la arquitectura siempre van encapsulados en los datos independientes de la arquitectura. Pondremos por ejemplo la estructura de información de una interposición. Esta estructura, a parte de indicar el tipo de interposición, el símbolo sobre el que actúa, el "backend" y función que reciben el control, debe guardar también como deshacerse más tarde así misma. Esta información depende mucho de la arquitectura y sistema operativo.
Generalmente toda la información que maneja pDI-Tools suele ir agrupada en estructuras. Así tenemos estructuras de información sobre las interposiciones, estructuras de información sobre los objetos en memoria, etc.
La información dependiente de la arquitectura podría ir mezclada en la misma estructura con la información independiente de la arquitectura. Luego, mediante construcciones #if ... #endif se podría activar y desactivar de una plataforma a otra. El problema con esta solución es que lleva a un código difícil de mantener, y lo peor de todo, no marca una separación clara entre la información dependiente de la independiente de la arquitectura.
Para resolver estos problemas se decidió que la información dependiente de la arquitectura debe encapsularse en una estructura que reciba el nombre "arch". Volviendo al ejemplo anterior, tendríamos:
typedef struct _tag_PDI_INTERCEPT { /* Información no-dependiente sobre la interposición */ int i_type; /* tipo de interposición */ char *i_symbol; /* símbolo al que afectará */ struct _tag_PDI_ELFOBJ *i_backend; /* backend que ejecuta esta interposición */ void *i_wrapper; /* función encargada de interponerse */ /* datos específicos según arquitectura */ PDI_ARCH_INTERCEPT *i_arch; /* Siguiente intercepción en la lista */ struct _tag_PDI_INTERCEPT *i_prev; struct _tag_PDI_INTERCEPT *i_next; } PDI_INTERCEPT;
Donde la estructura i_arch
es proporcionada
por el "Elf Backend" específico de la arquitectura. Nótese que en
realidad es un puntero a la información dependiente de la arquitectura
y no la información en sí. Los motivos son los siguientes:
Si el "Elf Backend" usa estas estructuras desde ensamblador, le facilitaremos el trabajo si en las estructuras independientes de la arquitectura usamos punteros. Al usar punteros a la información dependiente hacemos que sea más fácil predecir el tamaño de la estructura no independiente y por lo tanto facilitar el acceso a los campos de la estructura desde ensamblador.
Como se deja la gestión de memoria de la información independiente al "Elf Backend" este puede ejecutar sus propias optimizaciones.
No es habitual, pero sí posible, que en un determinado caso no haga falta información independiente de la arquitectura. En ese caso ahorraremos espacio en memoria siguiendo esta estrategia.