Implementación de una recarga en caliente de código C ++ en Linux

imagen


* Enlace a la biblioteca al final del artículo. El artículo en sí describe los mecanismos implementados en la biblioteca, con detalles medios. La implementación para macOS aún no está terminada, pero no es muy diferente de la implementación para Linux. Esto es principalmente una implementación para Linux.


Caminando por el github un sábado por la tarde, me encontré con una biblioteca que implementa la actualización del código de C ++ sobre la marcha para Windows. Yo mismo bajé de Windows hace unos años, no me arrepentí un poco, y ahora toda la programación se realiza en Linux (en casa) o en macOS (en el trabajo). Buscando en Google un poco, descubrí que el enfoque de la biblioteca anterior es bastante popular, y msvc usa la misma técnica para la función "Editar y continuar" en Visual Studio. El único problema es que no encontré ninguna implementación en aplicaciones que no sean Windows (¿me vi mal?). A la pregunta al autor de la biblioteca anterior si hará un puerto para otras plataformas, la respuesta fue no.


Debo decir de inmediato que solo estaba interesado en la opción en la que no tendría que cambiar el código del proyecto existente (como, por ejemplo, en el caso de RCCPP o cr , donde todo el código potencialmente recargado debería estar en una biblioteca separada cargada dinámicamente).


"¿Cómo es eso?" - Pensé, y comencé a encender incienso.


Por qué


Principalmente hago gamedev. La mayor parte de mi tiempo de trabajo lo paso escribiendo la lógica del juego y el diseño de cualquier visual. También uso imgui para utilidades de ayuda. Mi ciclo de trabajo con el código, como probablemente haya adivinado, es Escribir -> Compilar -> Ejecutar -> Repetir. Todo sucede bastante rápido (compilación incremental, todo tipo de caché, etc.). El problema aquí es que este ciclo debe repetirse con la suficiente frecuencia. Por ejemplo, estoy escribiendo una nueva mecánica de juego, que sea "Jump", un Jump válido y controlado:


1. Escribió un borrador de implementación basado en el impulso, ensamblado, lanzado. Vi que accidentalmente aplico un pulso a cada cuadro, y no una vez.


2. Fijo, montado, lanzado, ahora normal. Pero sería necesario tomar más el valor absoluto del impulso.


3. Fijo, montado, lanzado, funcionando. Pero de alguna manera se siente mal. Es necesario intentar sobre la base de la fuerza para hacer.


4. Escribió un borrador de implementación basado en fuerza, ensamblado, lanzado, trabajos. Sería necesario solo cambiar la velocidad instantánea en el momento del salto.
...


10. Fijo, ensamblado, lanzado, funcionando. Pero aún no es eso. Probablemente necesite probar una implementación basada en un cambio en gravityScale .
...


20. Genial, se ve super! Ahora sacamos todos los parámetros en el editor para gamediz, prueba y relleno.
...


30. El salto está listo.


Y en cada iteración, debe recopilar el código y en la aplicación iniciada llegar al lugar donde puedo saltar. Esto generalmente toma al menos 10 segundos. Y si solo puedo saltar en un área abierta, ¿cuál aún necesita ser alcanzado? ¿Y si necesito poder saltar sobre bloques con una altura de N unidades? Aquí ya necesito recopilar una escena de prueba, que también debe ser depurada, y que también necesita pasar tiempo. Es para tales iteraciones que una recarga en caliente del código sería ideal. Por supuesto, esto no es una panacea, no es adecuado para todo, y después de reiniciar a veces necesitas recrear parte del mundo del juego, y esto debe tenerse en cuenta. Pero en muchas cosas, esto puede ser útil y puede ahorrar atención y mucho tiempo.


Requisitos y declaración del problema.


  • Al cambiar el código, la nueva versión de todas las funciones debe reemplazar a las versiones anteriores de las mismas funciones.
  • Esto debería funcionar en Linux y macOS
  • Esto no debería requerir cambios en el código de la aplicación existente.
  • Idealmente, esto debería ser una biblioteca, estática o dinámicamente vinculada a la aplicación, sin utilidades de terceros
  • Es deseable que esta biblioteca no afecte mucho el rendimiento de la aplicación.
  • Suficiente si esto funciona con cmake + make / ninja
  • Es suficiente si funcionará con compilaciones de debazina (sin optimizaciones, sin recortar caracteres, etc.)

Este es el conjunto mínimo de requisitos que debe cumplir una implementación. Mirando hacia el futuro, describiré brevemente lo que se implementó adicionalmente:


  • Transferencia de valores de variables estáticas a código nuevo (consulte la sección "Transferencia de variables estáticas" para averiguar por qué esto es importante)
  • Recarga basada en dependencias (encabezado modificado -> reconstruido medio proyecto todos los archivos dependientes)
  • Recargar código de bibliotecas dinámicas

Implementación


Hasta ese momento, estaba completamente lejos del área temática, así que tuve que recopilar y asimilar información desde cero.


En un nivel alto, el mecanismo se ve así:


  • Monitoreamos el sistema de archivos para detectar cambios en la fuente
  • Cuando la fuente cambia, la biblioteca la reconstruye usando el comando de compilación de que este archivo ya estaba compilado
  • Todos los objetos recopilados están vinculados a una biblioteca cargada dinámicamente.
  • La biblioteca se carga en el espacio de direcciones del proceso.
  • Todas las funciones de la biblioteca reemplazan las mismas funciones en la aplicación.
  • Los valores de las variables estáticas se transfieren de la aplicación a la biblioteca.

Comencemos con lo más interesante: el mecanismo de recarga de funciones.


Funciones de recarga


Aquí hay 3 formas más o menos populares de reemplazar funciones en (o casi) tiempo de ejecución:


  • Truco con LD_PRELOAD : le permite construir una biblioteca cargada dinámicamente con, por ejemplo, la función strcpy , y hacer que cuando inicie la aplicación tome mi versión de strcpy lugar de la biblioteca
  • Cambiar tablas PLT y GOT : le permite "sobrecargar" las funciones exportadas
  • Conexión de funciones : le permite redirigir el hilo de ejecución de una función a otra

Las primeras 2 opciones, obviamente, no son adecuadas, ya que funcionan solo con funciones exportadas, y no queremos marcar todas las funciones de nuestra aplicación con ningún atributo. ¡Por lo tanto, el enganche de funciones es nuestra opción!


En resumen, el enganche funciona así:


  • La dirección de la función se encuentra
  • Los primeros pocos bytes de la función se sobrescriben mediante una transición incondicional al cuerpo de otra función.
  • ...
  • Beneficio!
    En msvc hay 2 indicadores para esto: /hotpatch y /FUNCTIONPADMIN . El primero al comienzo de cada función escribe 2 bytes, que no hacen nada, para su posterior reescritura con un "salto corto". El segundo le permite dejar un espacio vacío frente al cuerpo de cada función en forma de instrucciones de nop para un "salto largo" a la ubicación deseada, por lo que en 2 saltos puede cambiar de la función anterior a la nueva. Puede leer más sobre cómo se implementa esto en Windows y msvc, por ejemplo, aquí .

Desafortunadamente, no hay nada similar en clang y gcc (al menos en Linux y macOS). En realidad, este no es un problema tan grande, escribiremos directamente encima de la función anterior. En este caso, corremos el riesgo de tener problemas si nuestra aplicación es multiproceso. Si generalmente en un entorno de subprocesos múltiples restringimos el acceso a los datos por un subproceso mientras otro subproceso los modifica, entonces debemos limitar la capacidad de ejecutar código a un subproceso mientras otro subproceso modifica este código. No he descubierto cómo hacer esto, por lo que la implementación se comportará de manera impredecible en un entorno multiproceso.


Hay un punto sutil. En un sistema de 32 bits, 5 bytes son suficientes para "saltar" a cualquier lugar. En un sistema de 64 bits, si no queremos estropear los registros, necesitamos 14 bytes. La conclusión es que 14 bytes en la escala del código de máquina es bastante, y si el código tiene alguna función de código auxiliar con un cuerpo vacío, es probable que tenga menos de 14 bytes de longitud. No sé toda la verdad, pero pasé un tiempo detrás del desensamblador mientras pensaba, escribía y depuraba el código, y noté que todas las funciones están alineadas en un límite de 16 bytes (construcción de depuración sin optimizaciones, no estoy seguro sobre el código optimizado). Y esto significa que entre el comienzo de cualquiera de las dos funciones habrá al menos 16 bytes, lo que es suficiente para que los "atasquemos". Google superficial condujo aquí , sin embargo, no estoy seguro, tuve suerte, o hoy todos los compiladores hacen esto. En cualquier caso, en caso de duda, simplemente declare un par de variables al comienzo de la función stub para que sea lo suficientemente grande.


Entonces, tenemos el primer grano, un mecanismo para redirigir las funciones de la versión anterior a la nueva.


Buscar funciones en un programa copiado


Ahora necesitamos obtener de alguna manera las direcciones de todas las funciones (no solo exportadas) de nuestro programa o de una biblioteca dinámica arbitraria. Esto se puede hacer simplemente usando la API del sistema si los caracteres no están recortados de su aplicación. En Linux, estos son api de elf.h y link.h , en macOS, loader.h y nlist.h .


  • Usando dl_iterate_phdr revisamos todas las bibliotecas cargadas y, de hecho, el programa
  • Encuentra la dirección donde se carga la biblioteca
  • De la sección .symtab toda la información sobre los caracteres, a saber, el nombre, el tipo, el índice de la sección en la que se encuentra, el tamaño y también calculamos su dirección "real" en función de la dirección virtual y la dirección de carga de la biblioteca

Hay una sutileza. Al descargar un archivo elf, el sistema no carga la sección .symtab (corregir si está mal), y la sección .dynsym no nos conviene, ya que no podemos extraer caracteres con la visibilidad STV_INTERNAL y STV_HIDDEN . En pocas palabras, no veremos tales funciones:


 // some_file.cpp namespace { int someUsefulFunction(int value) // <----- { return value * 2; } } 

y tales variables:


 // some_file.cpp void someDefaultFunction() { static int someVariable = 0; // <----- ... } 

Por lo tanto, en el párrafo 3, no estamos trabajando con el programa que dl_iterate_phdr dio, sino con el archivo que hemos descargado del disco y analizado por algún analizador elfo (o en la api simple). Así que no nos perdemos nada. En macOS, el procedimiento es similar, solo los nombres de las funciones de la API del sistema son diferentes.


Después de eso, filtramos todos los caracteres y guardamos solo:


  • Las funciones que se pueden volver a cargar son caracteres del tipo STT_FUNC ubicados en la sección .text , que no son de tamaño cero. Tal filtro omite solo las funciones cuyo código está realmente contenido en este programa o biblioteca
  • Las variables estáticas cuyos valores desea transferir son caracteres de tipo STT_OBJECT ubicados en la sección .bss

Unidades de difusión


Para volver a cargar el código, necesitamos saber dónde obtener los archivos de código fuente y cómo compilarlos.


En la primera implementación, leí esta información de la sección .debug_info , que contiene información de depuración en formato DWARF. Para que cada unidad de compilación (ET) dentro de DWARF obtenga una línea de compilación para este ET, debe pasar -grecord-gcc-switches durante la compilación. Enano, analicé la biblioteca libdwarf, que viene incluida con libelf . Además del comando de compilación de DWARF, puede obtener información sobre las dependencias de nuestros ET en otros archivos. Pero rechacé esta implementación por varias razones:


  • Las bibliotecas son bastante pesadas.
  • Analizar una aplicación DWARF compilada a partir de ~ 500 ET, con análisis de dependencias, tomó un poco más de 10 segundos

10 segundos para iniciar la aplicación es demasiado. Después de pensarlo, reescribí la lógica de analizar DWARF para analizar compile_commands.json . Este archivo se puede generar simplemente agregando set(CMAKE_EXPORT_COMPILE_COMMANDS ON) a su CMakeLists.txt. Por lo tanto, obtenemos toda la información que necesitamos.


Manejo de dependencias


Como abandonamos DWARF, necesitamos encontrar otra opción, cómo manejar las dependencias entre archivos. Realmente no quería analizar archivos con mis manos y buscar include en ellos, y ¿quién sabe más sobre las dependencias que el compilador en sí?


Hay una serie de opciones en clang y gcc que generan los llamados depfiles casi gratis. Estos archivos utilizan los sistemas de compilación make y ninja para resolver dependencias entre archivos. Los archivos tienen un formato muy simple:


 CMakeFiles/lib_efsw.dir/libs/efsw/src/efsw/DirectorySnapshot.cpp.o: \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/base.hpp \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/sophist.h \ /home/ddovod/_private/_projects/jet/live/libs/efsw/include/efsw/efsw.hpp \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/c++/7.3.0/string \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/c++config.h \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/os_defines.h \ ... 

El compilador coloca estos archivos al lado de los archivos de objetos para cada ET, nos queda analizarlos y colocarlos en un hashmap. El análisis total de compile_commands.json + depfiles para el mismo 500 ET lleva un poco más de 1 segundo. Para que todo funcione, necesitamos agregar el indicador -MD globalmente para todos los archivos de proyecto en la opción de compilación.


Hay una sutileza asociada con ninja. Este sistema de compilación genera archivos sin importar la presencia del indicador -MD para sus necesidades. Pero después de que se generan, los traduce a su formato binario y elimina los archivos de origen. Por lo tanto, al iniciar ninja, debe pasar el -d keepdepfile . Además, por razones desconocidas para mí, en el caso de make (con la opción -MD ) el archivo se llama some_file.cpp.d , mientras que con ninja se llama some_file.cpp.od . Por lo tanto, debe verificar ambas versiones.


Transferencia Variable Estática


Supongamos que tenemos ese código (un ejemplo muy sintético):


 // Singleton.hpp class Singletor { public: static Singleton& instance(); }; int veryUsefulFunction(int value); // Singleton.cpp Singleton& Singletor::instance() { static Singleton ins; return ins; } int veryUsefulFunction(int value) { return value * 2; } 

Queremos cambiar la función veryUsefulFunction a esto:


 int veryUsefulFunction(int value) { return value * 3; } 

Al recargar, en la biblioteca dinámica con código nuevo, además de veryUsefulFunction , la variable static Singleton ins; , y el método Singletor::instance . Como resultado, el programa comenzará a llamar nuevas versiones de ambas funciones. Pero las ins estáticas en esta biblioteca aún no se han inicializado y, por lo tanto, la primera vez que se accede, se llamará al constructor de la clase Singleton . Por supuesto, no queremos esto. Por lo tanto, la implementación transfiere los valores de todas las variables que encuentra en la biblioteca dinámica ensamblada del código antiguo a esta biblioteca muy dinámica con el código nuevo junto con sus variables de protección .


Hay un momento sutil y generalmente insoluble.
Supongamos que tenemos una clase:


 class SomeClass { public: void calledEachUpdate() { m_someVar1++; } private: int m_someVar1 = 0; }; 

El método calledEachUpdate llama 60 veces por segundo. Lo cambiamos agregando un nuevo campo:


 class SomeClass { public: void calledEachUpdate() { m_someVar1++; m_someVar2++; } private: int m_someVar1 = 0; int m_someVar2 = 0; }; 

Si una instancia de esta clase se encuentra en la memoria dinámica o en la pila, después de volver a cargar el código, es probable que la aplicación se bloquee. La instancia asignada contiene solo la variable m_someVar1 , pero después del reinicio, el método calledEachUpdate intentará cambiar m_someVar2 , cambiando lo que en realidad no pertenece a esta instancia, lo que conduce a consecuencias impredecibles. En este caso, la lógica de transferencia de estado se transfiere al programador, que de alguna manera debe guardar el estado del objeto y eliminar el objeto en sí mismo antes de volver a cargar el código, y crear un nuevo objeto después del reinicio. La biblioteca proporciona eventos en forma de métodos delegados onCodePreLoad y onCodePostLoad que la aplicación puede procesar.


Creo que no sé cómo (y si) es posible resolver esta situación de manera general. Ahora este caso "más o menos normal" funcionará solo para variables estáticas, utiliza la siguiente lógica:


 void* oldVarPtr = ...; void* newVarPtr = ...; size_t oldVarSize = ...; size_t newVarSize = ...; memcpy(newVarPtr, oldVarPtr, std::min(oldVarSize, newVarSize)); 

Esto no es muy correcto, pero es lo mejor que se me ocurrió.


Como resultado, el código se comportará de manera impredecible si el tiempo de ejecución cambia el conjunto y el diseño de los campos en las estructuras de datos. Lo mismo se aplica a los tipos polimórficos.


Poniendo todo junto


Cómo funciona todo junto.


  • La biblioteca itera sobre los encabezados de todas las bibliotecas cargadas dinámicamente en el proceso y, de hecho, el programa mismo analiza y filtra caracteres.
  • A continuación, la biblioteca intenta encontrar el archivo compile_commands.json en el directorio de la aplicación y en los directorios principales de forma recursiva, y extrae toda la información necesaria sobre ET desde allí.
  • Al conocer la ruta de acceso a los archivos de objetos, la biblioteca carga y analiza los archivos.
  • Después de eso, se calcula el directorio más común para todos los archivos de código fuente del programa, y ​​la supervisión de este directorio comienza de forma recursiva.
  • Cuando un archivo cambia, la biblioteca busca ver si está en el hashmap de dependencias y, si lo hay, inicia varios procesos de compilación de los archivos modificados y sus dependencias en segundo plano, utilizando los comandos de compilación de compile_commands.json .
  • Cuando el programa le pide que vuelva a cargar el código (en mi aplicación, se le asigna la combinación Ctrl+r ), la biblioteca espera la finalización de los procesos de compilación y vincula todos los objetos nuevos a la biblioteca dinámica.
  • Esta biblioteca se carga en el espacio de direcciones del proceso dlopen función dlopen .
  • La información sobre los símbolos se carga desde esta biblioteca, y toda la intersección del conjunto de símbolos de esta biblioteca y los símbolos que ya viven en el proceso se vuelve a cargar (si es una función) o se transfiere (si es una variable estática).

Esto funciona muy bien, especialmente cuando sabes qué hay debajo del capó y qué esperar, al menos a un alto nivel.


Personalmente, me sorprendió mucho la falta de una solución para Linux, ¿alguien está realmente interesado en esto?


Estaré encantado de cualquier crítica, gracias!


Enlace a la implementación

Source: https://habr.com/ru/post/es435260/


All Articles