pDI-Tools: Mecanismo de interposición dinámica de código | ||
---|---|---|
Anterior | Capítulo 2. Trabajo realizado | Siguiente |
En esta sección comentamos limitaciones observadas en pDI-Tools y las evaluamos una por una. En esta evaluación sopesamos el tipo de limitación, el interés del usuario por que este implementada, la viabilidad de su resolución y como resolverla.
De esta discusión sacaremos como conclusión que mejoras propuestas se implementarán en el futuro y que otras no. Entre otros motivos para desestimar una mejora, tenemos que no sea de gran relevancia para el usuario, o bien que la solución implique demasiado coste de cálculo y por lo tanto lentitud, o bien implique una solución demasiado compleja que posiblemente nunca llegue a funcionar correctamente.
No es exactamente una limitación ya que se ha hecho intencionadamente y se podría considerar una "feature". No obstante mantener esta compatibilidad tiene un coste que se manifiesta en forma de ciertas limitaciones en pDI-Tools.
Con el tiempo se irá rompiendo poco a poco esta compatibilidad y así poder dar nuevas funcionalidades más potentes y sencillas de usar.
La limitación más importante de pDI-Tools viene dada por la estrategia usada para interponer código. No se pueden instrumentar llamadas estáticas dentro de un mismo DSO. Debido a que las llamadas dentro de un mismo DSO no hacen uso de las estructuras ELF, ya que son saltos relativos dentro del mismo objeto, pDI-Tools en principio no puede interponer código.
Se está contemplando, para el futuro, la posibilidad de interponer código
en estos casos usando la información que suministra ELF sobre los
símbolos estáticos junto a la información de depuración dada por el
estándar DWARF (es la información de depuración ofrecida por el
compilador). No obstante, si se implementa, este tipo de interposiciones
sólo se podrán realizar cuando se disponga de la información de
depuración (la información de depuración sólo se incluye si se compila el
programa con el "flag" -g
del compilador de C).
Además resolver este problema introduce problemas de portabilidad, ya no sólo entre sistemas operativos y arquitecturas, sino entre compiladores y herramientas de generación de binarios.
Otra importante limitación es la falta de un mecanismo similar al reenlace (o redefinición) que permita instrumentar funciones con una cantidad variable de parámetros.
Por ejemplo, supongamos que instrumentamos la función printf(2). Esta función recibe una cadena de formato y un número variable de parámetros. La cantidad de parámetros que se procesarán depende del contenido de la cadena de formato. Cuando un "wrapper" recibe el control no puede saber fácilmente cuantos parámetros se le han pasado: de hecho ni el mismo printf(2) lo sabe. Lo que hace una función con una cantidad de parámetros variables es procesarlos como una lista y parar cuando se cumple una cierta condición. Construir un mecanismo que copie una serie de parámetros no es una tarea sencilla ya que sería algo ligado a la arquitectura y totalmente específico para la función interceptada.
No obstante, si se dispone de una versión de la función que use una lista de argumentos variable (stdarg.h), se puede hacer un apaño. Por ejemplo, un posible "wrapper" de funciones printf(2) usando vprintf(2) sería:
static int printf_count = 0; int printf_wrapper(char *format, ...) { va_list args; int ret; printf_count++; va_start(args, format); ret = vfprintf(format, args); va_end(args); return ret; }
Esto tiene dos problemas: no instrumentamos printf(2), en realidad instrumentamos algo parecido. Y el peor: no siempre tenemos una versión de la función preparada para aceptar parámetros vía va_list.
En este último caso el único mecanismo que puede gestionar una función de parámetros variables es el "callback", debido a que nunca copia los parámetros, sino que entrega el control a la función interceptada como si virtualmente no se hubiese interpuesto ningún código.
Un problema al usar el mecanismo de "callback" es que es muy radical: o intercepta todas las llamadas de un objeto o no intercepta ninguna. Instalar un "callback" sólo para interceptar una función es algo un poco drástico. Hasta el momento no se ha implementado ningún mecanismo que permita reducir el radio de acción de un "callback" sólo a determinadas funciones.
La solución a esto no es técnicamente compleja y no sería difícil de implementar. No obstante esta es una limitación resultante de mantener la compatibilidad con DITools. En el futuro se añadirá esta funcionalidad o en todo caso una nueva interposición derivada del "callback" que actúe sólo sobre una colección de funciones.
Otro problema del "callback" es su alto consumo de memoria. Al igual que los reenlaces y en las redefiniciones gran parte del consumo de memoria se centra en salvar la información necesaria para poder desinstalarse posteriormente. Esto en un "callback" tiene una gran repercusión, ya que se instala sobre todas las funciones del DSO, y en caso de que estas sean muchas, representa guardar una importante tabla de punteros.
Además, por cada función interceptada del DSO, pDI-Tools debe crear un "stub" (pequeño bloque de código generado dinámicamente) que identifique la función y pase el control al "callback handler".
Todo junto representa un importante consumo de memoria a pesar de que se ha intentado reducir al máximo. Por ejemplo, pongamos que tenemos una librería para Linux™/INTEL® 386 con 1000 funciones e instalamos sobre ella un "callback". Se deberán salvar 1000 entradas del GOT (punteros) y generar 1000 "stubs". Teniendo en cuenta que cada "stub" ocupa 20 bytes y cada puntero 4 bytes se consumirán unos 24 kilobytes de memoria, que es relativamente poco, pero notable si se compara con un reenlace que con suerte consume unos 30 bytes de memoria.
Igualmente las redefiniciones actúan sobre todos los objetos instrumentables sin excepción. Es decir, no hay manera de hacer que uno o más objetos no sean afectados por una redefinición.
Evitar esto, al contrario que en el caso anterior, es algo complejo de resolver ya que requiere que pDI-Tools monitorice más intensamente la aplicación.
Instalar la redefinición y los reenlaces iniciales adecuadamente no es ningún problema. El problema surge cuando se carga, en tiempo de ejecución, un objeto que en teoría queremos excluir: el "Runtime Linker" al cargarlo intentará resolver las referencias aplicando, en contra de nuestra voluntad, la redefiniciones. Para evitar esto tendríamos que monitorizar todos los mecanismos de carga de objetos, y dependiendo de lo que estos hagan aplicar los reenlaces adecuados a mano.
Debido a que acotar el radio de acción de las redefiniciones sería muy costoso, tanto en complejidad como en lentitud, esta es una limitación que seguramente nunca se resolverá. Además no es habitual querer acotar una redefinición, así que la compensación obtenida no justificaría el esfuerzo.
En C es posible (y necesario en algunas ocasiones) obtener el puntero de una función. Según el ABI de muchas arquitecturas un puntero a una función sólo tiene sentido dentro del objeto donde trabajamos. Por ejemplo, supongamos que tenemos un programa formado por dos DSO. El primer DSO recoge un puntero a printf(2) y lo imprime, y el segundo hace exactamente lo mismo. El resultado de la ejecución sobre un SPARC (32 bits) podría ser:
MAIN: printf() = 0x8b001243 LIB: printf() = 0x8f223744 * las funciones NO coinciden *
¿Nos encontramos que hay dos funciones printf(2)? Bueno, en realidad no, en realidad sólo hay una. En SPARC el reenlace se implementa mediante "stubs" escritos en tiempo de ejecución. Lo que tenemos en realidad son punteros a dos "stubs" encargados de redirigir el control a la verdadera implementación de printf(2).
Pero esto no es siempre así. En algunas arquitecturas como PowerPC™ en modo 64 bits el resultado hubiese sido dos números idénticos:
MAIN: printf() = 0x00fa002b8b001243 LIB: printf() = 0x00fa002b8b001243 * las funciones COINCIDEN *
El motivo está en que esta arquitectura (y algunas más) distinguen el concepto de puntero a función de llamada a función. Es decir, si se consulta la dirección de una función, se devuelve la ubicación del código de la función. En cambio si llamamos a la función, en realidad se transfiere el control a otra parte (generalmente a un "stub" de reenlace). Ahora veamos que pasa si reenlazamos la función printf(2) en el objeto LIB. El resultado del programa sería:
MAIN: printf() = 0x00fa002b8b001243 LIB: printf() = 0x0bbb0c786ac766a7 * las funciones NO coinciden *
Tenemos dos valores distintos debido a que LIB en realidad está usando la versión de un "backend" de printf(2), y no la versión de C Library.
Esto implica que cualquier programa que confiase en comparaciones de funciones pasaría a funcionar de forma distinta cuando lo instrumentásemos. Este problema es también insoluble, aunque sólo afecta a los reenlaces y "callback" (las redefiniciones afectan a todas las referencias). Por suerte y de todas formas no es habitual encontrar programas que realicen comparaciones entre punteros a funciones, además que es algo muy poco portable.
Un problema existente, y de muy difícil solución, es que dependiendo de
como finalice su ejecución el programa principal pDI-Tools podrá finalizar
correctamente o no. pDI-Tools sólo finalizará su ejecución correctamente
cuando el programa termine con un exit(3) o debido a que termina el
código de main
.
En el caso de que el programa termine abruptamente (mediante un abort(3) o por generar una excepción grave), pDI-Tools no podrá ejecutar su código de finalización y por lo tanto la instrumentación quedará truncada y sin finalizar.
Esto implica que no se cierren correctamente los ficheros abiertos y se pierda la información todavía contenida en los "buffer" de escritura, o simplemente, se dejen los ficheros o bases de datos en un estado incoherente.
Debido a que pDI-Tools se ejecuta en modo usuario dentro de la misma aplicación, este problema no se puede evitar fácilmente. Siempre cabe la posibilidad de instalar "signal handlers" para cubrir una cantidad de casos, pero haciendo esto es posible entrar en conflicto con la aplicación instrumentada si esta también instala "signal handlers".
La última limitación impuesta es que no se permite que las interposiciones colisionen entre si. Es posible que el usuario piense que lo lógico fuese que si una interposición colisiona con otra, la última en aplicarse fuera la vigente.
No obstante este comportamiento no es aceptable por dos motivos: hay interposiciones de código como las redefiniciones que no se pueden deshacer fácilmente si entran en conflicto con otras interposiciones. Por otro lado esto implicaría unas estructuras de datos más complejas, una gestión más complicada y lenta y un mayor consumo de memoria, tres cosas inaceptables en un programa donde lo que prima es la velocidad.
La solución de DITools a este problema es simplemente no hacer nada. Es responsabilidad del programador que no hayan colisiones, y si las hay, el resultado es impredecible.