viernes, 15 de julio de 2016

Aplicando cambios sin reiniciar en C/C++ (toma 2)

Hace ya un tiempo les mostré en este blog dos trucos interesantes que puede hacer ZinjaI durante la depuración: uno para modificar atributos de objetos mientras el programa corre, y otro para modificar el ejecutable durante una pausa en la depuración y continuar sin reiniciar. Ambos se basan en automatizar y esconder ciertas secuencias de comandos gdb, y ambos tienen serias limitaciones, especialmente el segundo. Hoy les traigo una nueva alternativa, que resulta bastante más flexible, pero a diferencia de las anteriores, se basa en instrumentar el código. Vean primero el resultado en el siguiente video, y después les explico cómo se logra.


Si prestaron atención, lo que ven es que puedo modificar y agregar algunas funciones, hacer click en "ejecutar", y que el cambio se aplique directamente sobre una ejecución que ya había iniciado previamente, sin reiniciarla ni perder su estado. Más aún, esta es una ejecución común, sin el depurador. ¿Cuál es el truco? La idea detrás de esto es muy simple: las funciones que modifico estan en una biblioteca dinámica. Lo que hago es recompilar la biblioteca y mandarle una señal al programa para que vuelva a cargarla.

Vamos por partes. Primero: un poquito de marco teórico... Una biblioteca dinámica es (muy simplificado) un archivo con funciones ya compiladas. A diferencia de las bibliotecas estáticas, o los objetos intermedios, el contenido de la biblioteca dinámica no será parte del ejecutable, sino que permanecerá en un archivo separado. Cada vez que el ejecutable inicie, deberá ir a buscar ese archivo y encargarse de cargar en memoria sus funciones.

Normalmente, enlazamos la biblioteca al ejecutable mediante el linker del compilador (las opciones que empiezan con -l), y entonces el compilador ya se encarga de agregar en el ejecutable el código que busca y carga (con mucha ayuda del sistema operativo) la biblioteca antes de empezar con el main (nota al final*).

En bajo nivel, una función es como un puntero al lugar de la memoria donde está su código objeto. Cargar una biblioteca dinámica es cargar el archivo en memoria, y hacer que las funciones-punteros apunten a donde corresponda dentro del mismo. Con un poquito de trabajo extra podemos hacer esto "manualmente". Por ejemplo, en GNU/Linux sería algo como:

    // cargar el archivo de la lib en memoria
    void *handle = dlopen("debug.lnx/libdrawer.so",RTLD_LAZY);
    // buscar una función llamada "foo" dentro de esa lib

    typedef void (*pfunc_t)(); // tipo "puntero a funcion void()"
    pfunc_t foo = (pfunc_t) dlsym(handle, "foo");
    // a partir de ahora podemos usar foo como una función más
    foo();

Hay dos ventajas de hacerlo manualmente. Una es que puedo detectar el error si la biblioteca no está o no tiene la función (los punteros serían NULL) y tomar un camino alternativo sin esas funciones. Si fuera el linkeado estándar que hace el compilador, cuando la carga de la biblioteca falla el programa ni siquiera arranca. Esto suele usarse para, por ejemplo, cargar plugins o cualquier otro tipo de componente opcional de un sistema. La otra ventaja es que cuando quiera puedo descargarla, y eventualmente volverla a cargar luego:

    dlclose(handle); 
    // ahora handle y foo ya no son válidos
    handle = dlopen("debug.lnx/libdrawer.so",RTLD_LAZY);
    foo = (pfunc_t) dlsym(handle, "foo"); 

    // ahora foo vuelve a funcionar
    foo(); // llama a la nueva versión de "foo"

Y esto es lo que hago en el ejemplo, modifico una biblioteca que genera mi proyecto. El detalle adicional es que ese último código está en el manejador de una señal (con la misma idea de este post), y que el proyecto está configurado para ejecutar a través de un script de bash. El script mira si el proceso existe: si es así solo le envía la señal para que recargue la biblioteca; sino, lo lanza por primera vez:

    #!/bin/sh
    # ZinjaI pone en $Z_PROJECT_BIN la ruta completa del
    # ejecutable del proyecto. En FNAME extraigo solo el nombre
    FNAME=$(echo "$Z_PROJECT_BIN" | rev | cut -d '/' -f 1 | rev)
    if ! ps -C "$FNAME" > /dev/null; then
        "$Z_PROJECT_BIN" $Z_ARGS   # ejecutar
    else
        killall -TSTP $FNAME       # enviar señal
    fi


Y con toda esta combinación el resultado es el que ven en el video. Es relativamente muy poco código para una gran facilidad. Leí por ahí que esto se usa, por ejemplo, en videojuegos, para modificar la lógica del juego sin reiniciar el engine. Sin embargo, también tiene sus limitaciones. Las interfaces y los layouts internos de las estructuras de datos no deben cambiar para que el nuevo código sea compatible con lo que hay en memoria. Tampoco deberíamos confiarnos de variables globales o estáticas dentro de la biblioteca, ni cualquier otra cosa que haga que los estados sean incompatibles o se pierdan en la recarga. Sino habrá que trabajar más para serializar los datos antes de descargar la biblioteca y regenerar las instancias de objetos luego, y eso puede ser muy complejo.

* Una nota sobre ZinjaI: para que la carga manual reemplace a la del compilador, no hay que enlazar la biblioteca al ejecutable. En las versiones publicas de ZinjaI (hasta el momento, 15/07/16)  esto no se puede evitar. Si el proyecto genera la biblitoca, la enlaza. Existe una workaround con un #ifdef y dos perfiles de compilación, o con dos proyectos; pero para facilitar las cosas agregué esta opción y estará en el cuadro de configuración de bibliotecas para la próxima release.

No hay comentarios:

Publicar un comentario