7.2. Tipos de interposiciones

pDI-Tools incorpora tres mecanismos distintos de interposición de código. Los dos principales son los más sencillos de usar y están disponibles en todas las plataformas: los reenlaces y las redefiniciones. El otro mecanismo son los "callback" que permiten instrumentar una función de una forma totalmente invisible (ver motivos en la sección callback. En contrapartida es el mecanismo menos portable de los tres y en ocasiones es bastante complejo de usar.

En las secciones siguientes se comenta y explica como se implementa y funciona cada uno de ellos, dando también ejemplos de su uso.

7.2.1. reenlaces

Un reenlace o "relink" es la interposición de código más simple que se puede realizar, y a la vez la más fácil de controlar y que menos problemas causa.

Para especificar un reenlace se necesitan los siguientes parámetros:

Un reenlace consiste en sustituir la dirección la función "target function" en el GOT del objeto "target object", por la dirección del "wrapper" del "backend". De esta forma cada vez que el "target object" haga una llamada a la "target function", se transferirá el control al "backend". Evidentemente ningún otro DSO se verá afectado por esta operación ya que sólo se modifica la entrada del GOT que afecta al "target object".

En este esquema vemos como se instala un reenlace en la aplicación, exactamente sobre el programa principal, sin afectar al resto de objetos en memoria.

7.2.2. redefiniciones

Las redefiniciones se usan y configuran de un modo muy similar a los reenlaces. Sin embargo su semántica y efectos son muy distintos.

La idea de un reenlace es introducir una única interposición de código. En cambio una redefinición sustituye una función por otra.

Para especificar un reenlace se necesitan los siguientes parámetros:

La redefinición consiste en modificar en la tabla de símbolos de "target object" la entrada de la función a redefinir. En dicha entrada indicamos la dirección de nuestra implementación de la función. A partir de ahora, cada vez que un objeto use el símbolo redefinido en realidad se dará el control a la nueva definición.

En este esquema vemos como se sustituye la función read(2) de libc.so por nuestra versión de la función. Como se puede ver, esta sustitución afecta a todos los DSO de la aplicación.

Nota

Si el parámetro de configuración donttouch_backends está activado (por defecto sí), la redefinición no afectará a los "backend". En caso de activarse esta opción se debe trabajar con mucho cuidado ya que es fácil que se generen intercepciones recursivas. Por último, pDI-Tools sólo se verá afectado por las redefiniciones en caso de desactivarse el parámetro donttouch_pdi.

7.2.3. "callback"

El "callback", al contrario que los reenlaces y las redefiniciones, desde el punto de vista de un DSO no actúa sobre una única función sino sobre todas. Un "callback" permite instrumentar todas las llamadas que realiza un DSO a otros DSO.

Con los reenlaces o las redefiniciones, durante la ejecución del programa instrumentado, sólo se ejecuta código de los "backend" ya que pDI-Tools se ha encargado de redirigir el control hacia el código de los "backend". En el caso del "callback" interviene tanto código del "backend" como de pDI-Tools y ambos cooperan para realizar la interposición. En estas interposiciones quien recibe primero el control es la rutina "callback handler" (también llamada en ocasiones "generic wrapper"). Éste es un código que forma parte de pDI-Tools y se encarga de gestionar la intercepción. Este código exige al "backend" tener implementadas dos o tres funciones. La primera de todas, PDI_BE_FUNC_CB_REQ, debe decidir si la intercepción que ha realizado pDI-Tools interesa o no. Y las otras dos, PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_POST_CB, aportan el código a interponer antes y después de la llamada.

En el siguiente gráfico mostramos como cooperan entre si pDI-Tools y el "backend" para ejecutar la interposición de código.

En este diagrama se observa como al llamar a la función printf(2) desde MAIN en realidad se transfiere el control al "generic wrapper". Éste se encarga de transferir el control a las funciones de control de "callback" del "backend" y al verdadero código de printf(2).

El "callback handler" es una función, también llamada "generic wrapper", encargada de llamar a la función interceptada y a las funciones del "backend" encargadas de gestionar el "callback": PDI_BE_FUNC_CB_REQ, PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_POST_CB.

La función "callback handler" está escrita en ensamblador y es muy compleja y específica para la arquitectura. Es la encargada de realizar una interposición de tal forma que no haga falta copiar los parámetros de nuevo para poder ejecutar el código de la función interceptada, especialmente porque no se conocen. Esto nos obliga a salvar el estado de la pila y los registros. Salvando esta información es posible ejecutar el código pre-interpuesto, luego restaurar el estado inicial, y acto seguido saltar a la función interceptada. Al restaurar el estado exacto la función podrá recoger todos sus parámetros (sean por pila o registros) sin verse afectada por nuestras operaciones.

Al terminar la ejecución de la función el "callback handler" retomará el control, salvará el estado de los registros y la pila, ejecutará el código post-interpuesto, finalmente restaurará el estado final y retomará la ejecución donde tendría que haber vuelto la función.

Observemos que salvamos el estado de la aplicación antes de ejecutar el código interpuesto por las funciones PDI_BE_FUNC_PRE_CB y PDI_BE_FUNC_PRE_CB. Esto implica que estas funciones no pueden modificar los parámetros de entrada ni el valor retornado por la función.

Por último queda explicar la relación entre el mecanismo de "callback" y algunos parámetros de configuración que seguidamente enumeramos.

Antes de usar el mecanismo de "callback" debemos tener claro cuantos "threads" se usarán. Por cada "thread" el mecanismo de "callback" necesita una pila y estructuras de información sobre el "thread". Debido a ello debemos inicializar los parámetros max_threads y num_threads. El primero indica cuantos "threads" podemos instrumentar como mucho, mientras que el segundo indica cuantos "threads" esperamos usar.

La idea es que el parámetro max_threads se prepara para manejar un número máximo de "threads", mientras que el segundo, num_threads decide cuantos "threads" pre-inicializamos antes de comenzar la instrumentación. Si no se tiene claro cuantos "threads" se van a usar se aconseja un número alto en max_threads y 0 en num_threads.

Cada "thread" (una vez inicializado) tendrá asociado una pila donde guardará información de estado. El tamaño de esta pila viene determinado por el parámetro cb_stack_size. Por defecto se inicializa con un tamaño de 1024 bytes, que es más que suficiente para la mayoría de los casos. Sólo debería aumentarse si se realizan instrumentaciones que pueden desembocar en varios niveles de profundidad.

Un parámetro muy importante es cb_max_stubs. Este parámetro indica la cantidad de "stubs" para los que se reservará memoria. Por cada función de un DSO que se instrumenta se consume un "stub". Así si instrumentamos dos librerías, una con 100 funciones y la otra con 200, este parámetro debe valer como mínimo 300.

Por último tenemos el parámetro cb_allow_handler que permite usar, si lo deseamos, nuestro propio "generic wrapper" en las interposiciones mediante "callback". Evidentemente esta opción especial para usuarios muy avanzados.

Aviso

Si se instala un "callback" sobre un DSO luego ya no se puede instalar ningún otro tipo de interposición sobre cualquier función del mismo DSO ya que ambas entrarían en conflicto.