miércoles, 31 de agosto de 2016

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

Hace poco les conté cómo usar bibliotecas dinámicas para emular una suerte de "edit and continue" en cierto tipo de programas. La idea es simple: la parte que quería poder editar durante la ejecución (conjunto de clases y funciones) va a parar a una biblioteca dinámica. En lugar de dejar que el compilador la enlace al ejecutable, voy por la vía complicada y realizo la carga de la biblioteca "manualmente". Esto me da la posibilidad de descargarla y volverla a cargar en cualquier momento (cuando cambie). El post detallaba cómo hacerlo en GNU/Linux; pero ahora me di cuenta que en Windows hay cuatro problemas nuevos e interesantes para tener en cuenta.

El mismo video del post anterior, que muestra el resultado al que quiero llegar.

La forma en que se enlazan las bibliotecas dinámicas en GNU/Linux es muy diferente a la de Windows. En GNU/Linux todo está pensado para que practicamente no existan diferencias entre enlazado estático y dinámico. Gracias a esto, el código de la biblioteca no requiere demasiados cambios respecto a un código "normal". El único detalle destacable es la pseudo-necesidad de decorar las funciones que van en el dll y que el programa principal necesita encontrar con  extern "C" para evitar el mangling y facilitar su búsqueda luego.

En Windows, un dll es casi lo mismo que un exe (hasta tiene un entry-point). Entre otras consecuencias de esto, está que no pueden quedar símbolos no resueltos en su enlazado. En GNU/Linux, si el dll necesita acceso a variables que controla/genera el programa principal, no hay ningún problema. En Windows, el dll no enlazará porque no encontrará las definiciones de dichas variables. No alcanza con declaralas como "extern", eso es suficiente para generar una unidad de compilación, pero no para enlazar un exe o un dll. Y como tampoco se puede enlazar una biblioteca contra un exe (es al revés!), tuve que mover las definiciones de esas variables a un segundo dll contra el cual enlazan las dos partes originales.

El segundo problema es que hay que decorar una función o variable de una forma si la definición es parte de lo que estamos compilando, y de otra cuando se tomamos de un dll externo. Los famosos __declspec(dllexport) y __declspec(dllimport). Para evitar repetir las declaraciones y poder utilizar el mismo header desde todos lados se suele usar un #ifdef que selecciona la declspec que corresponde en base a una constante de preprocesador que se define o no en la llamada al compilador para cada fuente.

    #ifdef COMPILING_DLL
    #    define DECLSPEC __declspec(dllexport)

    #else
    #    define DECLSPEC __declspec(dllimport)
    #endif    DECLSPEC extern int main_win_id;
    DECLSPEC extern Point main_size;
    DECLSPEC extern std::vector<Point> vpoints;

Y con esto, más muchas pruebas y paciencia podemos resolver los problemas relacionados a C++ y el compilador. Faltan otros dos propios de Windows.


Por un lado, cuando Windows está utilizando un exe o dll, nadie (ningún otro programa) puede modificarlo. Entonces, mientras el programa corre y usa la dll, no podemos recompilarla para aplicar los cambios. Mi workaround para esto fue simplemente hacer que la función que carga el dll genere una copia temporal y cargue en realidad esa copia.

    void reload_dll() {
        static HINSTANCE handler = nullptr;
        // descargar versión anterior
        if (dll_handler) FreeLibrary(handler);
        // copiar y cargar la copia 
        CopyFile("mylib.dll","temp.dll",false);
        handler = LoadLibrary("temp.dll");
        // buscar y ejecutar una funcion
        func_t *pf  =
            (func_t*)GetProcAddress(handler,"alguna_funcion"); 
        pf();
    }

Y por último, para que esta recarga sea automática, en GNU/Linux yo ponía este código en el manejador de una señal y hacía que ZinjaI le envíe la señal al proceso cada vez que recompilaba. En Windows hay una variedad de señales mucho menor y su tratamiento es bastante particular, así que no pude utilizar este método. En lugar de ello, tuve que hacer que el bucle principal de mi programa verificara periodicamente la fecha de modificación del dll para detectar los cambios sin que nadie le avise.

En mi aplicación yo usaba GLUT, y los callbacks se definían en el dll. Pero no puedo instalar desde allí un callback para esta función ya que no sería seguro hacer la recarga del dll mientras alguna de sus funciones estén en el stack, así que tuve que agregarla de manera especial/particular desde la inicialización de la parte que va en el exe. Todo esto es más trabajo, y lo fácil o difícil que resulte depende mucho de cómo sea y quien controle a ese bucle principal.


Para cerrar, les dejo un enlace a un proyecto de ejemplo donde casi todo lo que tiene que ver con este truco, para ambos sistemas operativos, está aislado en una clase que podrían reutilizar.

2 comentarios:

  1. En códigos que son interpretados, es mas fácil hacer cambios en las subfunciones sin necesidad de volver a re-ejecutar el código. ¿esto es verdad?

    Se dice que se desaconseja usar variable globales en lenguajes declarativos (funciones realmente puras), también en imperativos (seudofunciones) ?

    https://es.wikipedia.org/wiki/Variable_global

    ResponderEliminar
    Respuestas
    1. Sí, es cierto que cualquier cambio "en caliente" suele ser mucho más simple en un lenguaje interpretado por la naturaleza propia de la compilación (con todo lo que eso implica) y porque el manejo de la memoria suele ser más indirecto.

      Sobre las variables globales... En un lenguaje funcional está claro que no, pues una función que usa variables globales no es una verdadera función pura. En otros lenguajes, aunque no sean de base funcional suele ser bueno adoptar algunas ideas y prácticas igual, para facilitar el análisis, la depuración, etc. Y además, si metés paralelismo en la ecuación también tenés nuevos problemas con las variables globales.

      Eliminar