En esta sección se da una pequeña introducción sobre como se enlaza en tiempo de ejecución objetos en formato ELF. Esta sección no pretende ser un manual detallado de ELF. Es simplemente una descripción de sus mecanismos y estructuras para poder entender más o menos como funciona. Si se desea tener un conocimiento detallado de ELF se recomienda leer la especificación [Sys5ABI]. En ella se encontrará una descripción general y no ligada a la plataforma del estándar. Para terminar de entender la implementación de ELF sobre plataformas MIPS® léase la especificación [Sys5mips], especialmente los capítulos 4 y 5.
El formato ELF, o Executable and Linking Format fue originalmente desarrollado y publicado por UNIX System Laboratories como parte de la Application Binary Interface (ABI). Más tarde el Tool Interface Standards committee (TIS) eligió el formato ELF como el formato portable de ficheros objeto en máquinas INTEL® de 32 bits para una amplia variedad de sistemas operativos.
El formato es muy extensible, y no sólo limitado a 32 bits para máquinas INTEL® 386. Actualmente se usa en una amplia variedad de máquinas, desde 32 bits hasta 64 bits pasando por todos los modelos concebibles de microprocesadores. No sólo el formato es ampliamente flexible, sino que además incorpora una serie de estructuras capaces de almacenar la información necesaria para saber sobre que arquitectura trabaja un determinado binario. Por ejemplo, en Itanium® es posible tener hasta cuatro ABI distintos funcionando en un mismo sistema operativo: 32 bit little endian, 32 bit big endian, 64 bit little endian y 64 bit big endian. El formato ELF permite al sistema operativo distinguir de que tipo es un determinado objeto, saber si puede usarlo o no y como usarlo.
La especificación de ELF se divide en dos partes: Los ficheros objeto y Carga del programa y enlace dinámico. En la primera parte se explica la estructura de los ficheros objeto, el formato binario en disco, y como se deben codificar e inicializar las estructuras. La segunda se refiere al formato ELF en tiempo de ejecución, con las estructuras ya descodificadas y con valores válidos, a parte de nuevas estructuras e información generadas por el "Runtime Linker". Casi todas las explicaciones que se dan a continuación giran alrededor de esta parte de la especificación, a no ser que se indique lo contrario. Es importante hacer esta distinción ya que el contenido de algunas secciones varía según este en disco o en memoria.
El motivo de que la primera parte de la especificación no nos interese mucho es porque DITools y pDI-Tools sólo trabajan con estructuras ELF una vez hecha la carga del programa y nunca manipulan ficheros en disco.
Hay muchos tipos de ficheros ELF: "Relocatable files" (ficheros reubicables), ficheros ejecutables, "Shared Objects", "core files", etc.
No obstante sólo dos de ellos intervienen en la ejecución de una aplicación: el fichero ejecutable y los "Shared Objects" (librerías). De hecho el primero contiene el programa y una lista de las librerías que se usan. Se usa esta lista para construir la lista de objetos compartidos que deben cargarse. Debe tenerse en cuenta que los objetos compartidos a su vez pueden requerir otras librerías y de esta forma generar un árbol de dependencias entre ellos.
Los ficheros ELF, ya sea en disco o en memoria, están divididos en secciones. Virtualmente una sección puede contener cualquier cosa: código, información de depuración, un icono, un MP3, una nota de texto con el copyright de la aplicación, etc. Las secciones son segmentos del fichero ELF que en principio no deberían solaparse (aunque nada se lo impide), con un nombre. Existen unas cuantas secciones predefinidas por el estándar, pero se pueden crear tantas como se quiera.
El responsable de la carga de los DSO (objetos) en memoria, de resolver los enlaces dinámicamente y poner en marcha la aplicación es el "Runtime Linker". Se explicará con detalle más adelante en esta sección. De momento sólo se explican los requerimientos genéricos que debe resolver cualquier "Runtime Linker".
Una vez se han cargado los objetos en memoria, el "Runtime Linker" recorre las diferentes secciones y estructuras de datos construyendo una cadena de estructuras dinámicas. Estas estructuras reciben el nombre de "Dynamic Tags" y contienen un identificador numérico que identifica el tipo de la estructura y, dependiendo de este tipo, la estructura puede contener un puntero (tipo ElfW(Addr)) o un valor (tipo ElfW(Word)).
Las estructuras dinámicas que contienen un valor del tipo ElfW(Word)
suelen dar información sobre otras secciones. Por ejemplo la sección
dinámica DT_STRSZ
indica el tamaño en memoria de la sección
"String Table".
Las estructuras que incorporan un puntero indican la ubicación en
memoria de una sección ELF. Por ejemplo la estructura DT_STRTAB
nos indica la posición en memoria de la sección "String Table".
Se ha de tener en cuenta que la cadena de "Dynamic Tags" no es sólo una
manera de agrupar la información para simplificar el acceso a ella.
Estas estructuras dinámicas sirven para seleccionar la información
relevante para el "Runtime Linker" y además pueden aportar información
nueva en tiempo de ejecución. Por ejemplo la sección dinámica
DT_DEBUG
está generada en tiempo de carga y suele apuntar a
información de depuración específica según el sistema operativo.
Como se ha dicho, la cadena de "Dynamic Tags" sólo agrupa información de interés para el "Runtime Linker", ignorando toda sección que el éste no use. Esto no perjudica al funcionamiento de DITools o pDI-Tools ya que estos nunca usan estructuras que use el "Runtime Linker".
Según la especificación [Sys5ABI], en tiempo de ejecución sólo se debería usar un conjunto de estructuras dinámicas predefinidas, aunque es posible no usarlas todas o añadir nuevas. Algunas arquitecturas aportan nuevos "Dynamic Tags", ignoran o incluso varían el comportamiento de algunos existentes.
La especificación hace un especial hincapié en que se respete la obligatoriedad y estructura de las secciones. Pero en la realidad ningún fabricante hace caso de la especificación. Así que a la hora de interpretar la especificación debemos ser bastante flexibles y apoyarnos sobre los "System V Application Binary Interface Supplements" específicos para las diferentes arquitecturas.
El problema radica en que ELF es un esfuerzo por definir una interfaz binaria común entre todos los Unix™. Evidentemente ELF entró en juego cuando ya había una gran diversidad de ABI's, y aunque la mayoría derivaron de COFF, las diferencias entre algunas eran realmente radicales. Evidentemente los fabricantes se negaron a reimplementar las ABI desde cero: esto hubiera roto la compatibilidad hacia atrás con sus antiguos productos. Lo que se hizo fue camuflar sus antiguos mecanismos de carga con una interfaz ELF, y sólo en algunos raros casos reimplementarla desde cero. De esta forma los binarios antiguos y ELF podían coexistir en un mismo sistema operativo. Algunos fabricantes lo hicieron implementaciones notablemente excelentes, como es el caso de INTEL® con su [Sys5i386] o Sun Microsystems™ con su [Sys5Sparc] y [Sys5Sparc64]. En cambio encontramos otros fabricantes que realizaron implementaciones que incumplen el estándar en numerosos puntos. Dos claros ejemplos son las especificaciones [Sys5mips] de SGI® y [PPC64ELF] de IBM®[1].
Reuniendo un conocimiento general de gran parte de las arquitecturas conseguimos tener una visión global de las implementaciones de ELF que nos permite ver que es lo que realmente se usa de la especificación, y lo más importante: que es más o menos común entre muchas arquitecturas.
De este estudio realizado remarcamos como importantes, y bastante comunes, un conjunto de estructuras dinámicas. No obstante no siempre están presentes o su contenido es muy especial en determinadas arquitecturas. Seguidamente las enumeramos y comentamos superficialmente:
DT_NULL
.
No aporta ninguna información, pero su presencia marca el final
de la lista de "Dynamic Tags". Su presencia, evidentemente, es muy
importante para poder iterar sobre esta lista.
DT_PLTGOT
.
Es de todas la sección más dependiente de la arquitectura. Según
el estándar debe estar siempre presente. En algunas plataformas,
aún así, puede no llegar a estar presente.
Según el estándar puede contener punteros (una "Global Offset Table" o GOT) y/o una serie de procedimientos de enlace ("Procedure Linkage Table" o PLT). La presencia de uno, u otro, o ambos, depende de la implementación.
DT_SYMTAB
.
Apunta a la "Symbol Table". Esta tabla sólo contiene
estructuras con información de un símbolo. Estas estructuras
básicamente contienen un puntero al nombre de la función en la
sección "String Table", información sobre el tipo de símbolo y
otra información complementaria.
DT_STRTAB
.
Este elemento apunta a la "String Table". Contiene el nombre de
los símbolos, el nombre de las librerías, y otras cadenas que
residen en esta tabla.
DT_HASH
.
Esta sección se ha encontrado en todas las plataformas y es
esencial para poder hacer búsquedas eficientes en la tabla de
símbolos.
Como el estándar es muy explicito en su implementación (incluso llegando a dar porciones de código que se deben usar), todas las plataformas probadas han demostrado ser idénticas en su gestión y creación.
Su función es acelerar al máximo las búsqueda de una estructura de
información de símbolo mediante su identificador. Se describen más
adelante estas estructuras en la sección dinámica DT_SYMTAB
.
DT_REL
o DT_RELA
.
Estos elementos guardan la dirección de su tabla de reubicación.
En una arquitectura determinada sólo se usa una de las dos,
aunque técnicamente sería posible usar ambas al mismo tiempo. A
partir de ahora nos referiremos sólo a DT_REL
, aunque todo lo
que digamos se aplica también a DT_RELA
.
La diferencia entre una sección y la otra es el tipo de estructura
usada. En el primero, DT_REL
, se usa una estructura de
reubicación ("relocation") absoluta, es decir, cuyo puntero se
puede usar tal cual. En el segundo se usa una estructura de
reubicación con un "addend" explícito que se ha de aplicar al
puntero antes de usarlo.
Estas estructuras relacionan el índice de una
estructura de información símbolo en la tabla de símbolos con un
puntero. Generalmente el puntero, o bien es la dirección del
símbolo en sí, o bien una referencia dentro de DT_PLTGOT
.
DT_RELSZ
o DT_RELENT
.
Dan información sobre el tamaño de la DT_REL
y el tamaño de la
estructura usada en la ella. En el caso de usarse DT_RELA
encontraremos también las estructuras dinámicas DT_RELASZ
o
DT_RELAENT
.
DT_PLTREL
y DT_PLTRELSZ
.
El primero indica el tipo de estructura de reubicación usada,
mientras que el segundo indica el tamaño de dicha estructura. El
primero puede tomar los valores DT_REL
o DT_RELA
.
Dejando de momento de lado las interrelaciones entre estas secciones,
se hace un inciso en la nomenclatura usada para los tipos de datos de
ELF. En ELF todos los tipos van precedidos por el prefijo
Elf32_ o Elf64_, según estemos en
una plataforma de 32 o 64 bits, respectivamente. Así tenemos
Elf32_Rel, Elf64_Sym, etc. Como la elección
del tipo de estructura se hace en tiempo de compilación, y no depende
de nosotros, sino del ABI escogido, accedemos a ellas mediante la
macro ElfW(x)
. Esta macro nos permite usar por
ejemplo el tipo ElfW(Sym) que se traducirá posteriormente, en tiempo
de compilación, en el tipo adecuado (Elf32_Sym o
Elf64_Sym).
Igualmente, las macros de ELF llevan todas el prefijo
ELF32_ o ELF64_. De la misma
forma usamos otra macro para nombrarlas: ELF(x)
.
Así tenemos las macros ELFW(R_SYM)
,
ELFW(R_TYPE)
, etc.
En esta sección describimos las interrelaciones que se dan entre las diferentes secciones en tiempo de ejecución. No obstante esta explicación la daremos mediante la descripción de las operaciones de consulta más usuales que se hacen.
Antes de comenzar observemos el siguiente diagrama. Este pequeño mapa nos facilitará tener un imagen global de como se relacionan las estructuras dinámicas más importantes.
Este diagrama muestra como el símbolo read(2) se declara en las estructuras ELF de algún DSO que lo use y/o implemente.
La primera operación que se plantea es la resolución de un símbolo a
partir de su nombre. En esta explicación se simplifica no teniendo en
cuenta modificadores como DT_SYMBOLIC
[2] ni los símbolos "weak"
(que se explican más adelante).
La búsqueda de un símbolo por su identificador se realiza con ayuda
de la tabla "hash" contenida en la sección DT_HASH
. Esta nos
permite acceder rápidamente a la información de un símbolo sin tener
que realizar una búsqueda secuencial por toda la tabla de símbolos.
La implementación y mecanismos de la tabla "hash" se describen hacia
el final de esta sección.
La sección DT_SYMTAB
contiene una serie de estructuras de
ElfW(Sym). Esta estructura contiene varios campos, de los cuales
los más interesantes son la posición del nombre del símbolo en
DT_STRTAB
, una dirección, el tamaño del símbolo (si es
aplicable), y un campo con información especial sobre el símbolo.
En el campo de información especial se indica la visibilidad y el tipo de símbolo. La visibilidad asigna en realidad un nivel de precedencia al símbolo por si hay colisiones con otros símbolos de otros objetos compartidos. Los niveles de precedencia son los siguientes:
STB_LOCAL
.
El símbolo sólo es visible desde este mismo objeto y no debe
exportarse. Los símbolos locales con el mismo nombre pueden
existir en varios ficheros sin interferir entre ellos.
STB_GLOBAL
.
Los símbolos globales son visibles a todos los objetos en
memoria. Una definición global de un símbolo satisfará
cualquier referencia no resuelta al mismo símbolo global.
STB_WEAK
.
Un símbolo débil ("weak") es equivalente a un símbolo global.
La diferencia estriba en que si existe un símbolo global con el
mismo nombre, este toma precedencia y el símbolo "weak" es
ignorado.
El tipo de símbolo es un identificador numérico que identifica el
tipo de objeto que la estructura declara. Las herramientas DITools
y pDI-Tools sólo trabajan con símbolos del tipo STT_FUNC
.
En el tipo de símbolo STT_FUNC
(sólo en los casos de visibilidad
STB_GLOBAL
y STB_WEAK
) el puntero de la estructura puede tomar
dos valores: 0 en caso de que el símbolo sea referido pero no resida
en el objeto, o un puntero real al símbolo dentro del objeto.
Con las operaciones hasta aquí descritas se puede escribir una rutina capaz de buscar un símbolo, averiguar de que tipo es, y en el caso de pDI-Tools o DITools, saber si es de nuestro interés y que operaciones podemos hacer sobre él.
La siguiente operación más común es realizar un enlace en tiempo de ejecución de un objeto con una definición de función en otro objeto.
Esta operación requiere que entre en juego tres nuevas secciones:
DT_REL
, DT_JMPREL
[3] y DT_PLTGOT
.
La sección DT_REL
contiene estructuras ElfW(Rel). Cada una de
estas estructuras relaciona un símbolo con su información de
reubicación. La estructura contiene un puntero, el tipo de estructura
de reubicación y el índice del símbolo con el que se relaciona.
El uso que se debe hacer del puntero depende del tipo de reubicación que estemos tratando. Estos tipos son dependientes de la arquitectura y se definen en los suplementos de [Sys5ABI].
Pero esta tabla generalmente no la usamos ya que en casi todas las
plataformas se separan las estructuras de reubicación relacionadas
con funciones en una sección aparte: DT_JMPREL
. Esta sección suele
contener sólo reubicaciones de un tipo, que indican la posición del
código/datos de reubicación dentro de la sección DT_PLTGOT
.
La operación comienza por buscar el símbolo a reenlazar en la tabla
de símbolos según se ha explicado más arriba. Una vez encontrado, nos
aseguramos de que el puntero del símbolo sea cero (o en otras
palabras que el símbolo no pertenezca al objeto). Una vez conocemos
el índice del símbolo dentro de la tabla de símbolos, buscamos en
DT_JMPREL
una estructura de reubicación con el índice de este
símbolo.
En la mayor parte de las arquitecturas esta búsqueda se puede realizar de forma dicotómica debido a que estas estructuras están ordenadas según el índice del símbolo.
Con la estructura de reubicación encontramos el lugar de DT_PLTGOT
que debemos alterar para ejecutar el reenlace. El contenido de esta
sección es muy variable, incluso en la misma plataforma según se use
un ABI de 32 o 64 bits. Podemos encontrar desde porciones
de código escrito en tiempo de ejecución hasta una simple tabla de
punteros (y a veces incluso mezclas de los dos).
Ponemos como ejemplo de sección DT_PLTGOT
el caso de la
arquitectura INTEL® 386 donde cada entrada de la sección DT_JMPREL se
corresponde con una entrada del PLT y contiene varios "stub" que
realizan el salto desde un DSO a otro DSO. Por ejemplo, si un
objeto A desea llamar a una función de B, A simplemente ejecuta una
llamada al "stub" en su sección DT_JMPREL
. Este "stub" a su vez
consulta otra sección dinámica, el GOT, donde encontrará la
dirección donde está la función de B, saltando de inmediato allí.
La función del "Runtime Linker" es ejecutar operaciones similares a las anteriores para resolver las llamadas entre objetos. Estas resoluciones las puede hacer en tiempo de carga, es decir todas de golpe, o bajo demanda, es decir cada vez que se llama a un símbolo por primera vez.
El primer caso se explica por si mismo. En cambio la resolución
dinámica de símbolos, o "Lazy Binding", es algo más compleja de
entender. El "Lazy Binding" consiste en no resolver ninguna referencia
en tiempo de carga. En su lugar se dejan referencias a un punto de
entrada especial del "Runtime Linker". Este código, cuando toma el
control, averigua a que símbolo se estaba intentando acceder. Con esta
información resuelve una dirección al verdadero símbolo (en otro
DSO), que luego usa para sustituir su entrada en el DT_PLTGOT
. Una
vez ha terminado de modificar el DT_PLTGOT
restaura el estado de la
aplicación y salta a la función que se estaba intentando llamar. A
partir de este momento los futuros accesos a este símbolo resolverán
directamente en el símbolo sin hacer falta la intervención del
"Runtime Linker".
Evidentemente este modelo presenta dos problemas. El primer problema radica en la posibilidad de que se intente resolver un símbolo que no existe en ningún DSO en memoria. Si ocurriese esto la aplicación terminaría bruscamente. El segundo problema surge de la lentitud de la resolución de un símbolo: aunque el resto de las llamadas tarden un tiempo razonable y constante, la primera vez que se resuelve el símbolo es difícil de predecir.
Normalmente no se realiza la resolución en tiempo de carga ya que este
tipo de resolución hace más lento el proceso de carga de una
aplicación. No obstante en algunos casos un software no puede tolerar
la impredecibilidad de la resolución de símbolos en tiempo de
ejecución. En este caso se puede declarar la variable de entorno
LD_BIND_NOW
que garantiza que todos los símbolos se resolverán en
tiempo de carga.
Hasta ahora nos hemos referido en muchas ocasiones al "Runtime Linker", pero no hemos definido todavía exactamente lo que es. El "Runtime Linker" es un objeto compartido, que se presenta en forma de librería, con algunas características especiales. El estándar ELF recomienda que el "Runtime Linker" sea la misma librería de C libc.so quien lo implemente. No obstante las implementaciones no hacen mucho caso de esta indicación del estándar y cada una usa un "Runtime Linker" distinto. Por ejemplo GNU/Linux™ usa ld-linux.so como "Runtime Linker", mientras que Solaris usa ld.so.1.
Se ha dicho que el "Runtime Linker" es un DSO especial ya que será usado directamente por el sistema operativo para cargar una aplicación. Cuando el sistema operativo desea cargar un ejecutable en memoria, lo único que hace es mapear el ejecutable en memoria (con un mmap(2)), mapear el "Runtime Linker", y transferir el control a él en modo usuario. Este se encargará de completar la carga del resto de objetos y de darle el control al programa.
Desde el punto de vista de la ejecución, al ser el "Runtime Linker" un objeto compartido más, es posible acceder a sus estructuras internas, a pesar de ser algo muy específico y poco portable entre diferentes sistemas operativos.
No obstante es necesario acceder a ellas para resolver unos cuantos problemas básicos. El estándar ELF sólo da mecanismos para poder acceder a las estructuras ELF de un objeto desde el mismo objeto. Es decir, un objeto puede consultar sus estructuras ELF, pero no tiene manera de navegar a través de las estructuras de los otros objetos.
Evidentemente el "Runtime Linker" suele guardar un inventario de los objetos que tiene en memoria, con referencias a sus estructuras. Como las herramientas DITools y pDI-Tools deben poder inspeccionar cualquier objeto en memoria requieren hacer uso de estas estructuras.
El acceso al inventario se hace de formas distintas según el sistema operativo: directamente, mediante protocolos de depuración o incluso mediante una API.
A veces se ofrecen mecanismos verdaderamente potentes y flexibles como es el caso de Irix, o parcos y escuetos como el de GNU/Linux™ y Solaris. En el primer caso no sólo se ofrecen mecanismos para acceder a las estructuras ELF, sino que además es posible manipular objetos en memoria, funciones para resolver símbolos, funciones de búsqueda, etc.
No obstante no se dispone siempre de una API tan potente como la de Irix ni tampoco está contemplado por el estándar. Por ese motivo es necesario poder iterar sobre los objetos para poder hacer las búsquedas nosotros mismos. El mecanismo usado para buscar un símbolo en un objeto se basa en usar una tabla "hash" nombrada antes más arriba.
El estándar ELF obliga a disponer de la sección DT_HASH
, que
contiene una tabla "hash" que permite hacer búsquedas de símbolos en
tiempo casi constante, junto a la información necesaria para poder
decodificarla. Esta sección se divide en dos partes, que aparecen en
este orden y sin separación alguna:
"bucket".
Contiene la tabla "hash". Cada entrada contiene un puntero a una
entrada de la siguiente sección o NULL
.
"chain".
Es una tabla donde se contienen todas las colisiones de la tabla
"hash". Cada entrada de esta tabla contiene un puntero a la
próxima colisión de símbolo o NULL
en caso de no haber más
colisiones.
La sección DT_HASH
está organizada de tal manera que cada entrada se
corresponde directamente con su entrada en la tabla de símbolos. De
esta forma, una vez encontrado el primer símbolo sólo tenemos que ir
siguiendo la cadena hasta encontrar el símbolo que resuelve nuestra
búsqueda o fracasar al agotar la cadena de búsqueda.
La función usada para calcular el "hash" de un símbolo está definida por el estándar y se respeta en todos los sistemas. Esta función devuelve un número de 32 bits. El valor final de "hash" lo obtenemos el resto de dividir el resultado de la función "hash" entre el número de entradas en el "bucket". Este valor es un índice del "bucket" y posiblemente la primera entrada de una cadena de colisiones.
Esta tabla nos permite reducir notablemente el tiempo empleado en las búsquedas y se debería usar (en el caso de pDI-Tools siempre) en todas las búsquedas por nombre de símbolo.
[1] | En realidad nunca se ha llegado a considerar un suplemento de [Sys5ABI] ya que incumple muchos requisitos mínimos. Al parecer, esta implementación para PowerPC™ 64 del ABI es en realidad un burdo disfraz de ELF para la especificación [POPEN32]. |
[2] | La presencia de este elemento altera el algoritmo de resolución del enlazador dinámico para referencias desde este DSO. En lugar de comenzar la búsqueda de la referencia en el ejecutable, la búsqueda comienza en el mismo objeto. En caso de que este DSO no aporte una definición del símbolo referenciado, el "Runtime Linker" comenzará de nuevo la búsqueda por el ejecutable y seguirá con el resto de objetos como suele ser habitual. |
[3] | En algunas plataformas como MIPS® esta sección no existe, aunque por regla general existe en todas las plataformas. |