Capítulo 3. Portabilidad e implementación en las diferentes plataformas

Tabla de contenidos
3.1. El formato ELF en diferentes sistemas
3.1.1. GNU/Linux sobre INTEL® 386
3.1.2. Solaris sobre SPARC
3.1.3. Irix sobre MIPS®
3.1.4. PowerPC™ sobre GNU/Linux
3.1.4.1. PowerPC
3.1.4.2. PowerPC™ 64
3.1.5. Organización del "Elf Backend"
3.2. Políticas seguidas para obtener un código portable
3.2.1. Variante de C usada
3.2.2. El preprocesador
3.2.3. Tipos de datos
3.2.4. Datos no-dependientes y dependientes de la arquitectura
3.3. Organización del código según la plataforma
3.3.1. Ficheros de cabecera de pDI-Tools
3.3.2. Código fuente de pDI-Tools
3.4. Herramientas

En este capítulo discutimos las decisiones tomadas y mecanismos usados para obtener un código altamente portable.

Se comienza por exponer las diferencias y similitudes entre distintas plataformas, tanto por sistema operativo como por arquitectura. Se exponen las plataformas soportadas actualmente: GNU/Linux sobre INTEL® 386, PowerPC™ y PowerPC™ 64; Solaris sobre SPARC; Irix sobre MIPS®.

De exponer estas plataformas, escribimos una sección dedicada a ver cuales son las diferencias y similitudes entre ellas. Explicaremos, muy por encima, las funcionalidades básicas que debe ofrecer la parte dependiente de la arquitectura para que pDI-Tools pueda desarrollar sus funciones. Esta capa recibirá el nombre de "Elf Backend" y ofrecerá un API a pDI-Tools que le permitirá desempeñar sus funciones sin preocuparse de que tipo de arquitectura o sistema tiene debajo.

En la siguiente sección se explican las decisiones tomadas para hacer el código portable. Buscamos portabilidad tanto a nivel de código como de herramientas de programación. Un programa fácilmente portable entre diferentes sistemas se consigue creando capas de abstracción, organizando bien el código, procurando no usar muchas estructuras e interfaces específicas de la arquitectura. Pero también se tiene que escribir el programa de tal forma que sea fácilmente compilable con otras herramientas.

De hecho el primer problema que se afronta al portar un programa es la disponibilidad de las herramientas con que se creo. Por ejemplo, a la hora de portar un programa desde Mac OS™ X a Solaris el primer problema es la inexistencia de las herramientas y librerías necesarias para SPARC. Esto significa un trabajo extra para crear el entorno de trabajo, los "scripts" de construcción del programa y encontrar herramientas equivalentes a las usadas en el sistema operativo de Apple Computer, Inc..

En la sección Políticas seguidas para obtener un código portable se discute que decisiones se han tomado para llegar a obtener un código portable tanto a nivel de compilador como de arquitectura.

En la sección Organización del código según la plataforma se discute desde como se organiza el código hasta que funcionalidades se implementan.

Finalmente, en las sección Herramientas explicamos que herramientas se usaron para desarrollar pDI-Tools, y en especial, en que contribuyen a que sea más portable.

3.1. El formato ELF en diferentes sistemas

pDI-Tools no trabaja directamente con el "hardware", aunque si es dependiente de tres componentes: el formato y suplemento ELF específicos de la arquitectura, la CPU para los fragmentos en ensamblador de pDI-Tools y como organiza la información del "Runtime Linker" el sistema operativo.

Por ello, en las siguientes secciones describiremos las principales características y diferencias de las arquitecturas sobre las que funciona pDI-Tools.

Veremos que hay básicamente tres tipos de mecanismos de enlace dinámico (todos ellos se tratarán con más detenimiento en las siguientes secciones):

Se mostrará que el formato ELF, a pesar de establecer con más o menos exactitud que estructuras deben usarse para organizar la información de un objeto, no explica como implementar el enlace dinámico en cada arquitectura. Además deja cabos en el aire que deben resolverse según las necesidades del fabricante. Esto es debido a que la especificación ELF no indica ninguna interfaz genérica para los "Runtime Linker" ni se ha llegado a ningún tipo de acuerdo entre fabricantes para producirla.

Con estas explicaciones reuniremos la suficiente información para poder entender, en la siguiente sección, como se estructuró la capa dependiente de la arquitectura de pDI-Tools.

3.1.1. GNU/Linux sobre INTEL® 386

Linux™ es un "kernel" libre de licencia GPL, creado por Linus Torvalds y una amplia comunidad de usuarios, que ha evolucionado mucho a lo largo de su vida. Linux™ fue inicialmente escrito exclusivamente para la plataforma INTEL® 386. Durante sus primeros años de vida básicamente creció hasta casi ofrecer las mismas funcionalidades que un "kernel" comercial de la época. Fue en la rama 2.2.x cuando se hizo un gran esfuerzo para crear un "kernel" fácilmente portable capaz de competir con otros sistemas estilo Unix™. Con esta versión aparecieron implementaciones de Linux™ para múltiples arquitecturas. Esto provocó que se empezasen a depurar mucho las interfaces del sistema operativo, cumpliendo cada día más los estándares. La versión 2.4.x se considera un "kernel" muy depurado, eficiente y apto para entornos de trabajo intenso, además de funcionar en un gran número de arquitecturas. Y ahora en la versión 2.6.x se está empezando a usar bastante en grandes computadoras y cálculo intensivo.

Linux™ además está íntimamente ligado a la librería de C GNU C Library (por ello suele recibir el nombre de GNU/Linux). Ésta, al igual que el "kernel", ha cambiado mucho a lo largo de su historia, soportando cada día mejor los estándares y mejorando su calidad y portabilidad.

El resultado, especialmente por los orígenes INTEL® 386 de Linux™, es una de las implementaciones de ELF más cuidadas y respetuosas con la especificación [Sys5ABI].

Sólo ha sido ahora con la versión 2.6.x del "kernel" y la versión 2.3.x de GNU C Library cuando se han introducido algunos cambios que rompen un poco la especificación ELF. No obstante los cambios no son muy bruscos y se resuelven con unas pocas condiciones.

El "Runtime Linker" de GNU/Linux ofrece un protocolo de depuración que permite acceder a la lista de objetos que él maneja. Esta lista es accesible desde la estructura global _r_debug. En esta estructura encontramos una lista encadenada de objetos, donde el primero es el binario de la aplicación y el resto las librerías y objetos dinámicos que ésta usa. Esta estructura y lista no forman parte del estándar y es el mecanismo que ld-linux.so (el "Runtime Linker") pone a nuestra diposición para acceder a la información sobre los DSO en memoria.

Cada elemento de la lista es una estructura _r_linkmap que nos permite llegar a la información ELF de un DSO. Examinando las estructuras ELF la primera sorpresa que tenemos es que en la información del ejecutable no tenemos el nombre de la aplicación. Por este motivo en GNU/Linux sobre INTEL® 386 pDI-Tools siempre se refiere al programa principal con el alias MAIN.

El suplemento del ABI para INTEL® 386 [Sys5i386] indica que en esta arquitectura siempre se ha de usar estructuras de reubicación con un "addend" implícito, o lo que es lo mismo, estructuras ElfW(Rel).

Esto implica que encontraremos seguro las secciones DT_REL, DT_RELSZ y DT_RELENT.

No obstante no las usaremos ya que GNU/Linux separa todas las reubicaciones de función en una tabla a parte: DT_JMPREL. Esta tabla está ordenada ascendentemente por el índice de símbolo de cada reubicación. Esto es muy práctico ya que nos permite hacer las búsquedas en esta tabla dicotómicamente.

Todas las entradas en la sección DT_JMPREL son del tipo R_386_JMP_SLOT. Este tipo de reubicación apunta a una entrada del GOT de la sección DT_PLTGOT del objeto. Esta entrada contiene la dirección real del objeto en memoria, o en su defecto un puntero a un "stub" encargado de llamar al "Runtime Linker" para que la resuelva.

Esta tabla se usa por unos "stubs" de reenlace generados en tiempo de compilación. Un "stub" se divide en dos partes: una que trata de transferir el control a la verdadera definición de la función, y otra encargada de transferir el control al "Runtime Linker" si la parte anterior no tuvo éxito.

Estos "stubs" están también contenidos en la sección DT_PLTGOT. Esta parte de la sección recibe el nombre de "Procedure Linkage Table". El aspecto del PLT es el siguiente:


.PLT0:  pushl   got_plus_4
        jmp     *got_plus_8
        nop ; nop
        nop ; nop
.PLT1:  jmp     *name1_in_got
        pushl   $offset
        jmp     .PLT0
.PLT2:  jmp     *name2_in_got
        pushl   $offset
        jmp     .PLT0
        ...
      

De este código podemos deducir que las tres primeras entradas del GOT están reservadas y su contenido es especial. La primera entrada, que no se usa en este código, contiene la dirección de la estructura dinámica _DYNAMIC. La segunda entrada sirve para dar información de identificación al "Runtime Linker", mientras que la tercera es un puntero a una función del "Runtime Linker" encargada de realizar las resoluciones en tiempo de ejecución.

Cuando el programa arranca la tabla GOT está inicializada de tal forma que la entrada name1_in_got contiene la dirección de la siguiente instrucción a .PLT1, la entrada name2_in_got contiene la dirección de la siguiente instrucción a .PLT2, y así sucesivamente.

Esto hace que la primera vez que usamos una función, el "stub" .PLTn provoque que se termine saltando a .PLT0 y de este al "Runtime Linker". El "Runtime Linker" alterará la entrada n del GOT con la verdadera dirección de la función para saltar directamente a ella en próximas llamadas.

El PLT se presenta en INTEL® 386 de dos formas: con direcciones estáticas para objetos no reubicables, como es el caso del binario ejecutable, o con "offsets" relativos, que es el caso de la mayor parte de las librerías compartidas.

Una última diferencia respecto al estándar, pero que no nos afecta muy seriamente, es el "Runtime Linker". La especificación recomienda que sea la misma librería de C el "Runtime Linker". Sin embargo, GNU/Linux usa el objeto compartido ld-linux.so como "Runtime Linker".

Realmente no es técnicamente más complejo darle el rol de "Runtime Linker" a GNU C Library. Los motivos que han llevado a separar esta funcionalidad a parte no están claros, aunque si es útil esta separación en algunos casos especiales. En concreto se nos ocurren dos posibles motivos:

  • Supongamos que escribimos una aplicación íntegramente en ensamblador y totalmente autosuficiente (no recurre a otras librerías, funciona exclusivamente mediante llamadas al sistema). Si el "Runtime Linker" está integrado en la librería de C obligamos a estas aplicaciones supercompactas a cargar esta macro librería, aumentando su consumo de memoria y tiempo de carga.

  • En el caso de los binarios enlazados estáticamente no es necesario usar la librería de C del sistema (ver [Teensy]). De hecho aplicaciones como versiones antiguas de Acrobat Reader vienen enlazadas estáticamente para evitar usar las librerías de sistema y así funcionar sobre casi cualquier distribución de GNU/Linux sin ser necesaria una recompilación. Estos binarios de por sí suelen ser muy grandes y consumen mucha memoria al incluir toda la librería de C, y además pueden dejar de funcionar correctamente si las llamadas al sistema operativo cambian.

3.1.2. Solaris sobre SPARC

El sistema operativo Solaris sobre SPARC tiene una implementación muy correcta del estándar ELF al igual que GNU/Linux sobre INTEL® 386.

Solaris es un sistema operativo desarrollado por Sun Microsystems™ a partir de la rama BSD, pero con muchas influencias de "System V" (de hecho ELF es parte de "System V"). Al contrario que GNU/Linux, Solaris es un sistema operativo Unix™ y no un clon.

La arquitectura SPARC es el buque insignia de Solaris. Sun Microsystems™ ha usado mucho y aún usa este tipo de CPU RISC. Se caracteriza por ser una arquitectura eficiente, clara, con un código ensamblador elegante y fácil de mantener. De hecho gracias a esto las SPARC modernas ofrecen total compatibilidad hacia atrás con CPU's más antiguas.

La arquitectura SPARC prácticamente solo ha sido usada por Sun Microsystems™ para sus estaciones de trabajo y servidores. Debido a esto el suplemento para SPARC de la ABI ha sido escrito casi por entero por Sun Microsystems™, a la vez que la implementación en Solaris. El resultado es una implementación bastante rigurosa de la misma.

A pesar de ello, en Solaris hemos encontrado algunos pequeños detalles que incumplen la especificación ELF. No parecen casuales ya que tienen la pinta de ser pequeñas optimizaciones. Por ejemplo, al igual que en GNU/Linux, encontramos que el "Runtime Linker" es una librería a parte llamada ld.so.1.

Para poder acceder a los datos de los objetos en memoria el "Runtime Linker" ofrece un protocolo de depuración basado en una estructura muy similar a _r_debug de GNU/Linux. Esta estructura recibe el nombre de r_debug.

No obstante esta estructura no está exportada globalmente y se ha de buscar en la sección dinámica DT_DEBUG de algún objeto. Esto nos lleva a una recurrencia: necesitamos consultar la sección DT_DEBUG pero no tenemos como llegar a las estructuras ELF de un objeto.

La especificación ELF, previsora, obliga a exportar la variable global _DYNAMIC a todos los objetos para resolver estas situaciones. Esta variable global es un puntero a la lista de secciones dinámicas del ejecutable. En esta lista precisamente encontraremos la sección DT_DEBUG que nos indicará la posición de la estructura r_debug que buscamos.

Esta estructura, al igual que su homóloga en GNU/Linux, contiene un puntero a una lista encadenada de objetos. Dentro de cada elemento de la lista encontraremos un puntero a la lista de estructuras dinámicas.

También encontramos que en algunos objetos faltan secciones como DT_RELA. Esto sucede en objetos especiales que no exportan ni usan símbolos externos. Estos objetos se encuentran en Solaris y en las nuevas versiones de GNU/Linux, y se desconoce su papel en el sistema, aunque tampoco afectan a nuestros propósitos. Estos objetos son marcados por pDI-Tools como Broken Objects (objetos rotos) para no ser manipulados.

Otra característica de esta arquitectura es que usa reubicaciones relativas, o lo que es lo mismo, del tipo ElfW(Rela). Estas estructuras se diferencian de ElfW(Rel) en que tienen un "addend" explícito. Este valor debe usarse con el puntero contenido en la estructura según el tipo de reubicación que sea.

Al usar estructuras ElfW(Rela) se deben usar las secciones DT_RELA, DT_RELASZ y DT_RELAENT. Estas, al igual que en INTEL® 386, contienen punteros al interior de la sección DT_PLTGOT. En concreto nos interesan las estructuras del tipo R_SPARC_JMP_SLOT, que son las encargadas de reubicar las funciones externas. No obstante esta vez los punteros de las estructuras ElfW(Rela) no apuntan a otros punteros, sino directamente a código.

La especificación de la arquitectura SPARC no usa en ningún momento tablas GOT, sino que reescribe en tiempo de ejecución el código de la tabla PLT.

Cada entrada del PLT es un "stub" encargado de realizar un salto a la verdadera definición de la función, o al "Runtime Linker" en caso de no conocerse todavía. Un "stub", debido a las limitaciones que impone el juego de instrucciones, se compone de unas cuantas instrucciones aritméticas que generan la dirección donde saltar y una instrucción de salto sin retorno.

Seguidamente copiamos un código de ejemplo de la tabla PLT ya inicializado por el "Runtime Linker":


.PLT0:   save    %sp, -64, %sp
         call    runtime_linker
         nop
.PLT1:   .word   obj_identification
         unimp
         unimp
         ...

.PLT101: sethi   %hi(name1), %g1
         jmpl    %g1+%lo(name1), %g0
         nop

.PLT102: sethi   (.-.PLT0), %g1
         ba,a    .PLT0
         nop

.PLT103: sethi   %hi(name3), %g1
         jmpl    %g1+%lo(name3), %g0
         nop

.PLTN:   sethi   %hi(nameN), %g1
         jmpl    %g1+%lo(nameN), %g0
         nop
      

El código en .PLT0 es el encargado de resolver un símbolo en tiempo de ejecución. Inicialmente los "stubs" tienen el aspecto del "stub" .PLT102. Este transfiere el control a .PLT0 junto a su posición en %g1. .PLT0 a su vez transferirá el control al "Runtime Linker". Éste se encargará de resolver la función, alterar el código en la posición .PLT102 y transferir el control a dicha función.

A partir de entonces los "stubs" toman el aspecto de los ejemplos .PLT101 y .PLT103. Estos "stubs" simplemente realizan un salto a la verdadera definición de la función.

En el caso de SPARC 64 bits, los "stubs" son distintos, además de más grandes. No obstante es prácticamente el mismo mecanismo.

3.1.3. Irix sobre MIPS®

Irix es el sistema operativo de SGI®. Está presente en muchas plataformas, entre ellas Itanium®. No obstante la más extendida y usada es para MIPS®. MIPS® es una CPU RISC de alto rendimiento, para 32 y 64 bits.

Este sistema operativo tiene una larga historia y soporta bastantes ABI. Actualmente sólo se usa, o se recomienda usar, el ABI de ELF en sus formas N32 (32 bits ELF) o 64 (64 bits ELF).

Por compatibilidad hacia atrás el "Runtime Linker", al contrario que SPARC o INTEL® 386, ofrece varios métodos para acceder a los objetos en memoria.

El que nos interesa es el acceso mediante la información de depuración en la estructura ElfW(Obj_Info). Esta estructura, a pesar de tener un nombre del estilo de los tipos de ELF, no forma parte del estándar ELF y es totalmente exclusiva de Irix. Esta estructura ofrece mucha información de depuración junto una lista encadenada con información sobre los objetos en memoria.

Y es en los elementos de esa lista donde encontramos una primera particularidad de este sistema operativo: los campos oi_ehdr y oi_orig_ehdr. Estos campos indican la dirección donde espera cargarse el objeto compartido, y la dirección donde realmente ha sido ubicado. La diferencia entre ambos campos se llama desplazamiento delta y se usa para recalcular los "offsets" indicados en algunas estructuras ELF.

De hecho el desplazamiento delta sólo se debe usar en las estructuras ELF que se han leído directamente de disco sin alterarse, como la tabla de símbolos o las tablas de reubicación. En otras estructuras generadas por el "Runtime Linker" como sería la tabla GOT se usan punteros absolutos.

Estos valores no se reajustan debido a que los ficheros binarios se mapean en memoria. De esta forma el programa no se copia entero a memoria sino que se consulta de disco de una forma transparente y sin consumir apenas memoria. Esto permite ahorrar bastante memoria, y además ya que, si están funcionando varias copias, todas comparten páginas cacheadas y por lo tanto se gana también rendimiento. No obstante es posible escribir en dichas páginas ya que por defecto están marcadas como páginas COW ("Copy-On-Write"), o lo que es lo mismo, cuando se modifican se reserva una página de memoria virtual anónima donde copiar el contenido y la modificación.

Por ello se debe detectar y tener en cuenta en que estructuras debemos usar el desplazamiento delta o no para interpretar sus valores.

La arquitectura MIPS® usa reubicaciones con "addends" implícitos, o lo que es lo mismo la estructura ElfW(Rel) y las secciones dinámicas DT_REL, DT_RELSZ y DT_RELENT.

Otra gran diferencia es la ausencia de una sección DT_JMPREL que acelere las búsquedas de reenlaces, pero como comentamos seguidamente, no es necesaria por la disposición que tienen las reubicaciones en la sección DT_REL.

De hecho en esta arquitectura pDI-Tools nunca llega a usar la tabla DT_REL. El suplemento ELF de MIPS® introduce muchos detalles sobre como debe organizarse la tabla de símbolos y la tabla GOT. Además añade nuevas secciones dinámicas con información para poder explotar dicha organización. Las estructuras dinámicas nuevas más importantes:

  • DT_MIPS_RLD_VERSION. Este elemento contiene la versión de la API del "Runtime Linker". Puede ser necesaria en el futuro para distinguir versiones de Irix que presenten alguna incompatibilidad con la versión actual.

  • DT_MIPS_LOCAL_GOTNO. Este elemento nos indica cuantas entradas del GOT pertenecen a símbolos locales. Es importante ya que nos permite dividir el GOT en dos partes claras, de las cuales sólo una debemos manipular.

  • DT_MIPS_GOTSYM. Por último este elemento nos da el índice del primer símbolo de la tabla de símbolos que tiene una entrada en la "Global Offset Table".

Las sección DT_PLTGOT se divide en dos partes lógicas: local y externa. Las entradas locales residen en la primera parte de la tabla. Estas entradas sólo requieren ser reubicadas si ocurren en un objeto compartido y la dirección donde se ha cargado dicho objeto no es donde esperaba ser cargado.

La sección de entradas externas reside en la segunda parte de la tabla GOT. La estructura dinámica DT_MIPS_LOCAL_GOTNO indica cuantos elementos contiene la tabla GOT local, y por lo tanto, donde comienza la tabla GOT externa.

Cada entrada en la parte externa del GOT se corresponde directamente con una entrada de una parte especial de la tabla de símbolos. Esta parte de la tabla de símbolos la llamamos .dynsym. La tabla de símbolos, de forma similar al GOT, se divide en dos partes: símbolos que no tienen una entrada en el GOT y símbolos que sí. Con ayuda de la información que nos da DT_MIPS_GOTSYM podemos conocer cual es la primera entrada de la tabla de símbolos que forma parte de .dynsym.

Toda esta información nos lleva a una conclusión: podemos conocer la posición en el GOT de una función tan sólo sabiendo la información de símbolo sin ser necesario consultar las estructuras DT_REL. Además el acceso será prácticamente directo debido al orden que hay entre DT_SYMTAB y DT_PLTGOT.

Por ejemplo, supongamos que queremos modificar la entrada del GOT del símbolo externo con índice symndx. Aplicando la siguiente operación aritmética obtenemos el índice de la entrada del GOT que debemos modificar:

gotndx = val(DT_MIPS_LOCAL_GOTNO) + symndx - val(DT_MIPS_GOTSYM)

Esto sin duda es mucho más rápido que una búsqueda en la tabla de reubicación, aunque ésta sea dicotómica.

Además el "Runtime Linker", que en Irix es una aplicación llamada rld(5), ofrece una completa API para manipular los objetos y símbolos en memoria. No obstante en pDI-Tools no se ha aprovechado esta API ya que parece muy dependiente de la versión de Irix y puede ser que no este presente en próximas versiones del sistema operativo. Manipulándolos con las funciones dlopen(3), dlsym(3) y dlclose(3) garantizamos que esta parte del código sea altamente portable sin perder apenas eficiencia (se supone que la implementación de estas funciones debe usar la API descrita).

Particularidades como esta hicieron tomar la decisión de separar el código para cada sistema operativo y arquitectura. No aprovechar algunas ayudas que brinda la plataforma Irix sobre MIPS® sería un error ya que obtendríamos soluciones menos eficientes.

Con esto se han explicado las diferencias más importantes de esta plataforma. Como se ha podido ver Irix sobre MIPS® es una plataforma con una implementación de ELF muy peculiar, tanto por sus optimizaciones como por las funcionalidades ofrecidas por el "Runtime Linker".

3.1.4. PowerPC™ sobre GNU/Linux

Esta explicación la dividimos en dos partes, la primera para PowerPC™ y la segunda para PowerPC™ 64 bits, debido no sólo a que son dos ABI completamente distintos, sino que además el segundo no forma parte del estándar ELF aunque sigue sus indicaciones.

3.1.4.1. PowerPC

PowerPC™ es una de las principales plataformas sobre las que corre GNU/Linux. Tiene una larga historia junto a una gran comunidad encargada de soportarla y mejorarla.

Debido a la buena portabilidad del sistema GNU/Linux, las diferencias entre el "Runtime Linker" de INTEL® 386 y PowerPC™ no son muchas, y casi todas las operaciones y funcionalidades se mantienen intactas de una implementación a la otra. Las estructuras de depuración _r_debug son casi idénticas y no presentan diferencias a la hora de manejarlas.

Encontramos también algunas discrepancias comunes respecto al estándar en ambas versiones (INTEL® 386 y PowerPC™). Por ejemplo, el objeto compartido ld-linux.so en ambas versiones asume el rol de "Runtime Linker", en lugar de la librería de C tal como aconseja el estándar.

Respecto a la implementación del ABI ELF para PowerPC™ podemos decir que está muy cuidada y es respetuosa con la especificación, a excepción de los aspectos que comentaremos seguidamente.

En esta arquitectura también nos encontramos con Broken Objects (ver GNU/Linux/INTEL® 386 o Solaris). Estos objetos tampoco son tratados en pDI-Tools y pasan desapercibidos durante la instrumentación.

El suplemento ELF de esta arquitectura es parecido a SPARC, pero con características de INTEL® 386. Se basa, al igual que INTEL® 386 en tener una a la vez una tabla PLT y una tabla GOT, pero se modifica el código en tiempo de ejecución al igual que SPARC. No obstante GNU/Linux no se mantiene fiel a la especificación ELF en este aspecto, e implementa un mecanismo de enlace algo distinto, aunque coherente con la especificación.

La implementación de los enlaces es casi idéntica a la implementación de SPARC en 32 bits. Sólo hay dos diferencias significativas: el nuevo juego de instrucciones de PowerPC™ y que las estructuras de reubicación no usan un "addend" explicito, es decir, se usan estructuras ElfW(Rel).

También encontramos que la sección DT_JMPREL también está presente, al igual que en GNU/Linux para INTEL® 386. En esta sección encontramos solamente estructuras del tipo R_PPC_JMP_SLOT. Estas estructuras son las encargadas de reubicar las funciones que usa o implementa un objeto.

Al igual que en SPARC, no usa en ningún momento tablas GOT, sino que reescribe en tiempo de ejecución el código de la tabla PLT.

Cada entrada del PLT es un "stub" encargado de realizar un salto a la verdadera definición de la función, o al "Runtime Linker" en caso de no conocerse todavía. Seguidamente mostramos el aspecto que puede tener el PLT de MAIN sólo iniciarse el programa en GNU/Linux sobre PowerPC™:


.PLTcall:
        addis   r11, r11, .PLTtable@ha
        lwz     r11, .PLTtable@l(r11)
        mtctr   r11
        bctr
.PLTresolve:
        addis   r12, r0, dynamic_linker@ha
        addi    r12, r12, dynamic_linker@l
        mtctr   r12
        addis   r12, r0, symtab_addr@ha
        addi    r12, r12, symtab_addr@l
        bctr
        nop
        nop
        nop
        nop
        nop
        nop
        nop
        nop
.PLT1:
        addi    r11, r0, 4*0
        b       .PLTresolve
.PLT2:
        addi    r11, r0, 4*1
        b       .PLTresolve
.PLT3:
        addi    r11, r0, 4*2
        b       .PLTresolve

        ...

.PLTN:
        addi    r11, r0, 4*(N-1)
        b       .PLTresolve
        

Cuando llamamos a una función, el control se transfiere a un "stub" como .PLT1, .PLT2... Estos "stubs" escriben su índice en el registro r11 y transfieren el control a .PLTresolve. Esta rutina llama a "Runtime Linker", pasándole el índice de la función en la tabla de símbolos y un puntero a dicha tabla. Con esta información el "Runtime Linker" resolverá la dirección del símbolo, y cambiará consecuentemente el contenido de la tabla PLT y GOT. Acto seguido transfiere el control a la función resuelta.

Pero GNU/Linux en esta ocasión no se mantiene fiel a la especificación y e introduce aquí una optimización copiada de la plataforma SPARC. La rutina .PLTcall está implementada en esta plataforma al principio de la sección DT_PLTGOT, al contrarió del estándar, que indica que tendría que ser la segunda rutina. Además, si se puede, el código que se copia es distinto del expuesto: transfiere el control directamente a la función sin usar la tabla GOT. Seguidamente se explica cuando se usa este código o el otro.

Si la diferencia entre el punto de entrada de la función desde la posición y .PLTi está en un rango de 0x03ffffff bytes se realiza un salto relativo a la función, y por lo tanto se escribe esta entrada en el PLT:


PLTi:
        b       (@target - .PLTi)
        

Si la dirección de la función está demasiado lejos, es decir, no entra en el rango antes descrito, se realiza el enlace mediante una consulta al GOT y un salto a a función .PLTcall, tal como indica el estándar:


PLTi:
        addi    r11, r0, 4*(i-1)
        b       .PLTcall
        

En próximas llamadas, el "stub" transferirá el control directamente si es un salto cercano, o mediante la rutina .PLTcall a la dirección de memoria almacenada en la posición i-1 de la tabla GOT.

Con esta optimización se consigue acelerar notablemente la velocidad del enlace en muchísimos casos sin romper la compatibilidad con el estándar.

3.1.4.2. PowerPC™ 64

GNU/Linux sobre PowerPC™ 64 es un "porting" relativamente reciente. Está dirigido considerablemente por IBM® e implementa soluciones similares a las de IBM AIX en el "kernel". Debido a que este formato no se considera parte de ELF no se entrará en mucho detalle sobre él.

IBM AIX originalmente usaba un ABI distinto de ELF llamado PowerOpen™. El formato de los ejecutables PowerOpen™ era una variación del formato COFF y adaptado a PowerPC™. Posteriormente se adaptó el "Runtime Linker" de IBM AIX para que pudiera usar binarios ELF.

De esto resultó unas indicaciones, que no se consideran parte del estándar ELF, sobre como implementar algo parecido a ELF siguiendo un ABI similar al de PowerOpen™ y por lo tanto un esquema de enlace también similar.

El resultado final es una especificación muy distinta a todas las anteriores, y a la vez muy vaga e inexacta.

Con el desarrollo del sistema operativo GNU/Linux se ha hecho un esfuerzo por retomar este formato de enlace e intentar convertirlo en algo parecido a ELF. No obstante la implementación no sigue ni la especificación resultante ni tampoco las antiguas especificaciones ELF de IBM AIX.

De hecho en esta arquitectura, a pesar de que existe una sección DT_PLTGOT, su contenido no es útil. Los enlaces se realizan todos en tiempo de carga mediante la construcción de descriptores de función.

Un descriptor de función es una estructura de esta forma:


typedef struct function_descriptor {
        void *addr;
        unsigned long toc;
        unsigned long env;
} f_desc_t;
        

El campo addr contiene la dirección del punto de entrada de la función. El campo toc contiene la dirección base del TOC para esta función. El campo env contiene el puntero de entorno para lenguajes como Pascal o PL/1.

Estos descriptores tendrían que estar contenidos en una tabla llamada TOC contenida en la sección DT_PLTGOT.

Cuando un programa quiere llamar a una función, este consulta el descriptor de la función, carga en el registro r2 (o registro TOC) el valor correcto del TOC para esta función, consulta su dirección en el descriptor y salta a ella.

Aprovechando esto se construyen los reenlaces: para reenlazar una función a otra simplemente tenemos que copiar su descriptor encima del descriptor de la función interpuesta.

Este mecanismo es el único posible ya que la mayoría de estructuras ELF están corruptas en esta arquitectura. Con esto quiero decir que las tablas de símbolos están vacías o faltan símbolos, punteros que no apuntan a ninguna parte e incoherencias.

Respecto al "Runtime Linker", éste tiene el mismo comportamiento que en PowerPC™ 32 bits. Por ello el "driver" de sistema apenas se ve afectado por esta nueva arquitectura.

3.1.5. Organización del "Elf Backend"

Las descripciones anteriores nos muestran que las diferencias generalmente se suelen dar a nivel del "Runtime Linker". Respecto a las implementaciones de ELF, con mayores o menores innovaciones, éstas se suelen respetar bastante la especificación ELF: tabla de símbolos, tabla de cadenas, formato de las estructuras, etc.

Rara poder manejar todas estas diferencias se debe decidir como se organizarán los "Elf Backend" para poder obtener una portabilidad óptima sin sacrificar la legibilidad del programa.

La idea más inmediata es dividir los "Elf Backend" en unidades, que llamaremos "drivers". Es fácil pensar en dos tipos de "drivers": los dedicados a interaccionar con el "Runtime Linker" y por lo tanto dependientes del sistema operativo, y los dedicados a realizar los enlaces en una cierta arquitectura (dependientes de la CPU).

A priori la organización más sencilla sería una comunicación directa entre todos los "drivers" y la parte independiente de la arquitectura, como se puede ver en el siguiente diagrama:

En este diagrama mostramos como todas las comunicaciones serían a un mismo nivel. Es decir, pDI-Tools usaría un "driver" del sistema operativo para interaccionar con el "Runtime Linker", y otro de CPU para realizar las interposiciones.

Pero a pesar de que los mecanismos de enlace (dependientes de la CPU) parecen a priori muy portables entre sistemas operativos, hay ligeras diferencias impuestas por los sistemas operativos que hacen que sean difíciles de reutilizar en su totalidad.

Además apostar por fijar un API demasiado estricta para ELF haría que ciertos casos como PowerPC™ 64 fuesen muy difíciles de resolver, además de en el futuro vetar el soporte a otros ABI que no sean ELF.

Por ello, aprovechando que los sistemas operativos suelen ir muy ligados a las arquitecturas sobre las que funcionan, se hará una implementación de estos "drivers" menos portable, pero muchísimo más compacta. Y por ello estos deberán replicarse para cada sistema operativo, aunque por su reducido tamaño (unas 200 líneas de código en el caso de GNU/Linux) no implicará un gran problema.

De este modo la comunicación se realizará de la siguiente forma: la parte independiente solicitará operaciones al "Elf Backend", que en realidad será directamente un "driver" para el sistema operativo. Este llamará posteriormente al "driver" de CPU adecuado para ejecutar las interposiciones:

En este diagrama podemos ver como del "driver" de un sistema operativo penden sus "drivers" específicos para CPU.

De esta forma podemos camuflar el tratamiento de ABI's algo distintas de ELF como sería el caso de PowerPC™ 64.

Además se reduce notablemente el API que expondrá el "Elf Backend" a la parte no dependiente de la arquitectura y a los "backend" de usuario. Esta fuerte separación reducirá la cantidad de condicionales en el código, haciéndolo más compacto, legible y eficiente.

A la hora de implementar el "driver" de sistema operativo nos encontramos con diferencias entre sistemas, pero no demasiado fuertes, ya que se puede seguir un hilo general. Por ello se traza un esquema que se seguirá en todos los "drivers" de los diferentes sistemas operativos.

Hacer esto facilita el trabajo de crear un "driver" para un nuevo sistema operativo a partir de otro ya existente. El "driver" de sistema operativo debe facilitar a pDI-Tools la gestión de los objetos en memoria, y además aislar los mecanismos de reenlace usando los servicios del "driver" de CPU.

Vistas las secciones anteriores observamos que la primera tarea que debe resolverse es como acceder a las estructuras ELF. Para ello el "Elf Backend" debe ser responsable de construir una lista de objetos, para que la parte independiente pueda trabajar con ellos sin preocuparse de como se organiza el "Runtime Linker".

Cada nodo de la lista de objetos de pDI-Tools contendrá información sobre el objeto. Esta información será recogida de los "Dynamic Tags" de los objetos en memoria, tratada si es necesario y precalculada en algunos casos. Como veremos más adelante, la información sobre cada objeto en memoria, irá contenida en una estructura PDI_ELFOBJ, donde cada estructura contiene un enlace a la información de objeto anterior y posterior, según el orden de la lista de objetos del "Runtime Linker".

Parte de esta información se presentará al código no dependiente de la plataforma, mientras que el resto será para uso interno del "Elf Backend".

Esta información no sólo acelerará y simplificará la obtención de un cierto dato durante la ejecución, sino que además centrará el proceso de los "Dynamic Tags" a un sólo punto del programa.

Como hemos visto, por la presencia o ausencia de los "Dynamic Tags" cuando se dan diferencias incluso entre diferentes versiones de un mismo sistema operativo. Por ejemplo, en GNU/Linux 2.6.x con GNU C Library 2.3.x, nos encontraremos con objetos que carecen del "Dynamic Tag" DT_REL y DT_RELA, mientras que con GNU C Library 2.2.x siempre está presente. En estos casos se adjunta en la información de objeto una marca, que llamaremos "BrokenObject", que indica que el objeto no se puede instrumentar.

Tambien encontramos otras diferencias que no quebrantan el estándar pero implican muchas distinciones posteriores. Por ejemplo en Irix la la sección DT_JMPREL no existe. Esta sección es opcional, según el estándar, aunque por regla general está presente en el resto de sistemas operativos. Dejando los mecanismos de búsqueda dentro del "Elf Backend" no sólo aislamos la parte independiente un poco más de ELF, sino que obtenemos un código más simple, más específico y más rápido.

El resto de diferencias afecta directamente al contenido de las secciones ELF. Algunos "Runtime Linker" a veces modifican el contenido de las secciones (respecto a su aspecto en disco) por motivos de optimización. Esto implica que el código específico para un determinado sistema operativo, aunque no sea excesivamente diferente al de otro sistema operativo, esté lleno de pequeños ajustes para poder utilizar dichos datos.

Por último encontramos que algunas implementaciones proporcionan nuevos "flags" o datos que pueden facilitar y acelerar algunos mecanismos y que por lo tanto vale la pena aprovechar.

PDI_ELFOBJ. Estas estructuras contienen mucha información precalculada y con ella se consigue acelerar notablemente la ejecución de pDI-Tools ya que reduce la cantidad de código replicado y cálculo.

Ejemplos de esta información son punteros a las principales secciones ELF, valores extraídos de las mismas, nombre del DSO, su alias, punto de carga del DSO, etc.

Pero no en todas las plataformas nos interesa la misma información. Por ello esta estructura de información se divide en una parte común para todas las plataformas (no dependiente de la arquitectura) y una parte específica, establecida en tiempo de compilación, totalmente dependiente de la arquitectura.

Por último explicamos que labores debe cumplir la capa encargada de gestionar la CPU.

El "driver" de CPU puede realizar tres posibles operaciones: establecer los reenlaces, establecer las redefiniciones y establecer las interposiciones mediante "callback". De estas tres sólo son frecuentes las dos primeras.

No se establece un API común para estas operaciones, y se deja libre frente a cualquier necesidad del "Elf Backend". No obstante, hasta la fecha se sigue el mismo esquema para todas las implementaciones.

En la siguiente sección describimos a grandes rasgos el API que ofrece el "Elf Backend" para realizar todas las operaciones antes citadas.

3.1.5.1. API de un "Elf Backend"

En esta sección enumeramos una por una las funciones que debe implementar cara a pDI-Tools el API de un "Elf Backend". Antes de comenzar a enumerarlas, damos unas pinceladas generales para entender mejor este API.

Se pretende mantener un API lo más reducida y simple posible. Se observa que cada punto de entrada tiene un objetivo claramente definido aunque en ningún momento se marca el formato del ABI que se trata por debajo.

Pero se hace una excepción con la gestión de los "callback". Debido a la complejidad de los "callback" es muy difícil decidir una única estructura que deban seguir todas las arquitecturas. De hecho puede ser hasta contraproducente ya que implicaría mayores niveles de abstracción, más complejidad y consiguientemente más código y lentitud. Por lo tanto se deja en manos del programador de la arquitectura como se implementará el mecanismo de "callback" y todas las estructuras en memoria que este requiera.

Respecto al resto el funcionamiento es bastante claro, y como veremos el "Elf Backend" implementa funcionalidades básicas como su propia inicialización y finalización, resolución de símbolos, aplicar y deshacer las interposiciones de código y gestión de los "callback".

int _pdi_arch_init(void);

int _pdi_arch_fini(void);

Descripción. Estas funciones se encargan de inicializar y finalizar el "Elf Backend", respectivamente. La primera se llama durante la inicialización de pDI-Tools y la segunda durante su finalización.

Generalmente un "Elf Backend" no suele requerir una inicialización previa. Sin embargo hay casos como Solaris donde se deben realizar unas cuantas operaciones para poder obtener información clave para poder trabajar con las estructuras ELF. O el caso de GNU/Linux, que debemos averiguar antes que versión de GNU C Library se está ejecutando ya que en la versión 2.3.x se han introducido cambios importantes en el "Runtime Linker". Para inicializar y finalizar el "Elf Backend" se deben implementar las funciones _pdi_arch_init() y _pdi_arch_fini().

Parámetros. Estas funciones no reciben parámetros de entrada.

Valor devuelto. 0 si todo fue bien, -1 en caso de error.

int _pdi_arch_initSafeFuncs(void);

Descripción. Esta función se encarga de obtener punteros reales a las funciones que se usarán para evitar que pDI-Tools sea instrumentado accidentalmente.

Parámetros. Esta función no recibe parámetros de entrada.

Valor devuelto. 0 si todo fue bien, -1 en caso de error.

int _pdi_arch_initObjectList(void);

int _pdi_arch_refreshObjectList(void);

Descripción. La función _pdi_arch_initObjectList() crea una copia independiente de la plataforma de la lista de objetos del "Runtime Linker". La función _pdi_arch_refreshObjectList() nos permitirá resincronizar esta copia con la lista actual del "Runtime Linker".

Con estas funciones podemos implementar casi toda la lógica de la aplicación en la parte no dependiente de la arquitectura y reducir notablemente la cantidad de código en los "Elf Backends". La mayor parte de pDI-Tools es código de gestión de las estructuras en memoria y comprobaciones simples como si está o no cargado un DSO en memoria, resolver referencias a objetos tanto por nombre como por alias, comprobar que las interposiciones no se interfieren, etc.

Parámetros. Estas funciones no reciben parámetros de entrada.

Valor devuelto. 0 si todo fue bien, -1 en caso de error.

void _pdi_arch_finiObjectList(void);

Descripción. Esta función se encarga de liberar la memoria de la lista de objetos independiente de la plataforma. Se le llama durante la finalización de pDI-Tools.

Parámetros. Esta función no recibe parámetros de entrada.

Valor devuelto. Esta función no devuelve nada.

void *_pdi_arch_resolveSymbol(char *name);

void *_pdi_arch_resolveSymbolInObject(PDI_ELFOBJ *obj, char *name);

Descripción. Para resolver un símbolo el "Elf Backend" debe ofrecer las funciones _pdi_arch_resolveSymbol() y _pdi_arch_resolveSymbolInObject(). La primera función busca entre todos los objetos en memoria un puntero al símbolo en el objeto que lo exporta. La segunda busca la información de un símbolo en un determinado objeto. No debe interpretarse como "buscar un símbolo en el espacio de nombres de un determinado objeto": simplemente busca la implementación del símbolo en el objeto, y si fracasa devuelve NULL.

Parámetros.

  • obj. Indica en que objeto debemos buscar el símbolo indicado por el parámetro name.

  • name. Es el nombre del símbolo que pretendemos resolver.

Valor devuelto. Se devuelve un puntero al punto de entrada de la función en caso de éxito, o NULL en caso de no poder resolverse.

int _pdi_arch_symbolIsUsedOrReferenced(PDI_ELFOBJ *obj, char *name);

Descripción. Esta función indica si el símbolo de nombre name es referenciado o implementado por el objeto obj (o ninguno de los dos).

Parámetros.

  • obj. Indica en que objeto debemos buscar la información del símbolo indicado por el parámetro name.

  • name. Es el nombre del símbolo que pretendemos examinar.

Valor devuelto. Devuelve 0 si el símbolo name no se usa ni se implementa por obj. Devuelve un valor positivo si está implementado por el objeto, o un valor negativo en caso de sólo usarse por él.

int _pdi_arch_initCallback(void);

int _pdi_arch_finiCallback(void);

Descripción. Estas funciones se encargan de inicializar y finalizar el sistema de gestión de interposiciones por "callback" dependiente de la arquitectura.

Parámetros. Esta función no recibe parámetros de entrada.

Valor devuelto. Esta función devuelve un número natural.

int _pdi_arch_newInterposition(PDI_ELFOBJ *eo, char *wname, PDI_INTERCEPT *i);

Descripción. Esta función da de alta la información dependiente de la arquitectura en una interposición. Sólo se actualizan y preparan las estructuras de datos, no se instala la interposición. Para instalar la interposición se deberá llamar posteriormente a la función _pdi_arch_callback(), _pdi_arch_redefine() o _pdi_arch_relink().

Parámetros.

  • eo. Objeto en el que damos de alta la interposición.

  • wname. Nombre de la función "wrapper", o lo que es lo mismo, nombre de la función que interponemos.

  • i. Interposición donde añadimos la información dependiente de la arquitectura.

Valor devuelto. Devuelve 0 en caso de éxito, o -1 en caso de algún error.

int _pdi_arch_callback(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

Descripción. Esta función realiza la acción de instalar una interposición de código mediante el mecanismo de "callback" el objeto eo.

Parámetros.

  • eo. Objeto sobre el que estableceremos las interposiciones por "callback".

  • i. Estructura que define la interposición que se va a instalar, es decir, se indica el tipo de interposición y sus parámetros.

Valor devuelto. 0 en caso de éxito, -1 en caso de error.

int _pdi_arch_redefine(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

Descripción. Estas función instala una redefinición sobre la función implementada en el objeto eo.

Parámetros.

  • eo. Objeto que contiene la implementación de la función a redefinir.

  • i. Estructura que define la interposición que se va a instalar, es decir, se indica el tipo de interposición y sus parámetros.

Valor devuelto. 0 en caso de éxito, -1 en caso de error.

int _pdi_arch_relink(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

Descripción. Esta función se encarga de instalar un reenlace sobre las llamadas a una función desde el objeto eo.

Parámetros.

  • eo. Objeto sobre el que instalaremos el reenlace.

  • i. Estructura que define la interposición que se va a instalar, es decir, se indica el tipo de interposición y sus parámetros.

Valor devuelto. 0 en caso de éxito, -1 en caso de error.

int _pdi_arch_undoCallback(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

int _pdi_arch_undoRedefine(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

int _pdi_arch_undoRelink(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

Descripción. Estas funciones sirven para desinstalar los diferentes tipos de interposiciones de código.

Parámetros.

  • eo. Objeto al que pertenece esta interposición.

  • i. Interposición a desinstalar.

Valor devuelto. 0 en caso de éxito, -1 en caso de error.

int _pdi_arch_deactivateRedefinition(PDI_INTERCEPT *i, PDI_ELFOBJ *eo);

Descripción. Desactiva la redefinición i en el objeto eo. Esta función se usa cuando se carga un "backend" en memoria en tiempo de ejecución para que no se vea afectado por las redefiniciones instaladas.

Parámetros.

  • i. Redefinición que desactivamos.

  • eo. Objeto en el que desactivamos la redefinición.

Valor devuelto. 0 en caso de éxito, -1 en caso de error.

void _pdi_arch_freeInterposition(PDI_ELFOBJ *eo, PDI_INTERCEPT *i);

Descripción. Esta función libera la memoria usada por la información dependiente de la arquitectura de la interposición i instalada en el objeto eo. No desinstala la interposición, para ello ha de usarse una de las funciones _pdi_arch_undoCallback() _pdi_arch_undoRedefine() o _pdi_arch_undoRelink().

Parámetros.

  • eo. Objeto en el que está dada de alta la interposición.

  • i. Interposición en la que quitaremos la información dependiente de la arquitectura, liberando memoria.

Valor devuelto. Devuelve 0 en caso de éxito, o -1 en caso de algún error.

int _pdi_arch_defaultMaxStubs(void);

Descripción. Esta función aconseja el tamaño del "pool" de "stubs" por defecto para esta plataforma. Generalmente la máxima cantidad de "stubs" que quepan en una página de memoria.

Parámetros. Esta función no recibe parámetros de entrada.

Valor devuelto. Esta función devuelve un número natural.

int _pdi_arch_newElfObj(PDI_ELFOBJ *eo, int objpool_index);

Descripción. Esta función reserva una estructura de información especifica de la arquitectura sobre un objeto (una estructura PDI_ARCH_ELFOBJ) y la asigna al objeto eo.

pDI-Tools no reserva memoria dinámicamente para las estructuras de información sobre los objetos independiente de la arquitectura. En su lugar, al iniciarse, reserva un "pool" de objetos estático. Por ello, y en caso del que "Elf Backend" quisiese organizar de la misma manera, se le pasa el índice de la información no dependiente en el "pool" de objetos.

Parámetros.

  • eo. Información no dependiente de la arquitectura del objeto que se le quiere añadir la información dependiente.

  • objpool_index. Índice en el "pool" de objetos del objeto eo.

Valor devuelto. Devuelve 0 en caso de éxito, o -1 en caso de algún error.

void _pdi_arch_freeElfObj(void);

Descripción. Libera la memoria ocupada por la parte dependiente de la arquitectura en una estructura PDI_ARCH_ELFOBJ.

Parámetros.

  • eo. Objeto en el que liberamos memoria.

  • objpool_index. Índice en el "pool" de objetos del objeto eo.

Valor devuelto. Devuelve 0 en caso de éxito, o -1 en caso de algún error.