Implementación de una recarga en caliente de código C ++ en Linux y macOS: profundizando


* Enlace a la biblioteca y video de demostración al final del artículo. Para entender lo que está sucediendo y quiénes son todas estas personas, recomiendo leer el artículo anterior .


En el último artículo, nos familiarizamos con un enfoque que permite una recarga en caliente del código c ++. El "código" en este caso son funciones, datos y su trabajo coordinado entre sí. No hay problemas especiales con las funciones, redirigimos el flujo de ejecución de la función anterior a la nueva, y todo funciona. El problema surge con los datos (variables estáticas y globales), es decir, con la estrategia de su sincronización en el código antiguo y nuevo. En la primera implementación, esta estrategia fue muy torpe: simplemente copiamos los valores de todas las variables estáticas del código antiguo al nuevo, de modo que el nuevo código, refiriéndose a las nuevas variables, funciona con los valores del código anterior. Por supuesto, esto es incorrecto, y hoy trataremos de corregir esta falla resolviendo simultáneamente una serie de problemas pequeños pero interesantes.


El artículo omite detalles sobre el trabajo mecánico, como la lectura de personajes y reubicaciones de archivos elf y mach-o. El énfasis está en los puntos sutiles que encontré en el proceso de implementación, y que pueden ser útiles para alguien que, como yo recientemente, está buscando respuestas.


Esencia


Imaginemos que tenemos una clase (ejemplos sintéticos, no busquen significado en ellos, solo el código es importante):


// Entity.hpp class Entity { public: Entity(const std::string& description); ~Entity(); void printDescription(); static int getLivingEntitiesCount(); private: static int m_livingEntitiesCount; std::string m_description; }; // Entity.cpp int Entity::m_livingEntitiesCount = 0; Entity::Entity(const std::string& description) : m_description(description) { m_livingEntitiesCount++; } Entity::~Entity() { m_livingEntitiesCount--; } int Entity::getLivingEntitiesCount() { return m_livingEntitiesCount; } void Entity::printDesctiption() { std::cout << m_description << std::endl; } 

Nada especial sino una variable estática. Ahora imagine que queremos cambiar el método printDescription() a:


 void Entity::printDescription() { std::cout << "DESCRIPTION: " << m_description << std::endl; } 

¿Qué sucede después de recargar el código? Además de los métodos de la clase Entity , la variable estática m_livingEntitiesCount también m_livingEntitiesCount a la biblioteca con el nuevo código. No pasará nada malo si simplemente copiamos el valor de esta variable del código antiguo al nuevo y continuamos usando la nueva variable, olvidando la anterior, porque todos los métodos que usan esta variable directamente están en la biblioteca con el nuevo código.


C ++ es muy flexible y rico. Y aunque la elegancia de resolver algunos problemas en c ++ bordea el código maloliente, me encanta este lenguaje. Por ejemplo, imagine que su proyecto no usa rtti. Al mismo tiempo, debe tener una implementación de la clase Any con una interfaz de tipo seguro:


 class Any { public: template <typename T> explicit Any(T&& value) { ... } template <typename T> bool is() const { ... } template <typename T> T& as() { ... } }; 

No entraremos en detalles sobre la implementación de esta clase. Lo importante para nosotros es que para la implementación necesitamos algún tipo de mecanismo para el mapeo inequívoco del tipo (entidad de tiempo de compilación) en el valor de una variable, por ejemplo, uint64_t (entidad de tiempo de ejecución), es decir, "enumerar" tipos. Cuando utilizamos rtti, tenemos a nuestra disposición cosas como type_info y, más adecuado para nosotros, type_index . Pero no tenemos rtti. En este caso, un truco bastante común (¿o una solución elegante?) ¿Es esta función:


 template <typename T> uint64_t typeId() { static char someVar; return reinterpret_cast<uint64_t>(&someVar); } 

Entonces la implementación de la clase Any se verá así:


 class Any { public: template <typename T> explicit Any(T&& value) : m_typeId(typeId<std::decay<T>::type>()) // copy or move value somewhere {} template <typename T> bool is() const { return m_typeId == typeId<std::decay<T>::type>(); } template <typename T> T& as() { ... } private: uint64_t m_typeId = 0; }; 

Para cada tipo, la función se instanciará exactamente 1 vez, respectivamente, cada versión de la función tendrá su propia variable estática, obviamente con su propia dirección única. ¿Qué sucede cuando recargamos el código usando esta función? Las llamadas a la versión anterior de la función serán redirigidas a la nueva. La nueva tendrá su propia variable estática ya inicializada (copiamos el valor y la variable de protección). Pero no nos interesa el significado, solo usamos la dirección. Y la dirección de la nueva variable será diferente. Por lo tanto, los datos se volvieron inconsistentes: en las instancias ya creadas de la clase Any , la dirección de la antigua variable estática se almacenará, y el método is() comparará con la dirección de la nueva, y "este Any ya no será el mismo Any " ©.


Plan


Para resolver este problema, necesita algo más inteligente que simplemente copiar. Después de pasar un par de noches en Google, leer la documentación, los códigos fuente y la API del sistema, se me ocurrió el siguiente plan:


  1. Después de construir el nuevo código, pasamos por las reubicaciones .
  2. De estas reubicaciones obtenemos todos los lugares en el código que usan variables estáticas (y a veces globales).
  3. En lugar de direcciones a nuevas versiones de variables, sustituimos las direcciones de versiones antiguas en el lugar de reubicación.

En este caso, no habrá enlaces a datos nuevos, toda la aplicación continuará funcionando con versiones antiguas de variables hasta la dirección. Eso debería funcionar. Esto no puede dejar de funcionar.


Reubicaciones


Cuando el compilador genera código de máquina, inserta varios bytes suficientes para escribir la dirección real de la variable o función en este lugar en cada lugar donde se llama la función o se carga la dirección de la variable, y también genera una reubicación. No puede registrar de inmediato la dirección real, porque en este momento no conoce esta dirección. Las funciones y variables después del enlace pueden estar en diferentes secciones, en diferentes lugares de secciones, en las secciones finales se pueden cargar en diferentes direcciones en tiempo de ejecución.


La reubicación contiene información:


  • ¿Qué dirección necesita para escribir la dirección de la función o variable?
  • La dirección de qué función o variable escribir
  • La fórmula por la cual se debe calcular esta dirección
  • ¿Cuántos bytes están reservados para esta dirección?

En diferentes sistemas operativos, las reubicaciones se representan de manera diferente, pero al final todas funcionan con el mismo principio. Por ejemplo, en elf (Linux), las reubicaciones se ubican en secciones especiales .rela (en la versión de 32 bits, esto es .rel ), que se refieren a la sección con la dirección que debe corregirse (por ejemplo, .rela.text , la sección en la que se ubican las reubicaciones, aplicado a la sección .text ), y cada entrada almacena información sobre el símbolo cuya dirección desea insertar en el sitio de reubicación. En mach-o (macOS), lo opuesto es el caso; no hay una sección separada para reubicaciones; en cambio, cada sección contiene un puntero a una tabla de reubicaciones que debe aplicarse a esta sección, y cada registro en esta tabla tiene una referencia a un símbolo relacional.
Por ejemplo, para dicho código (con la opción -fPIC ):


 int globalVariable = 10; int veryUsefulFunction() { static int functionLocalVariable = 0; functionLocalVariable++; return globalVariable + functionLocalVariable; } 

el compilador creará una sección con reubicaciones en Linux:


 Relocation section '.rela.text' at offset 0x1a0 contains 4 entries: Offset Info Type Symbol's Value Symbol's Name + Addend 0000000000000007 0000000600000009 R_X86_64_GOTPCREL 0000000000000000 globalVariable - 4 000000000000000d 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 0000000000000016 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 000000000000001e 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 

y tal tabla de reubicación en macOS:


 RELOCATION RECORDS FOR [__text]: 000000000000001b X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000015 X86_64_RELOC_SIGNED _globalVariable 000000000000000f X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000006 X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 

Y aquí está la función veryUsefulFunction() (en Linux):


 0000000000000000 <_Z18veryUsefulFunctionv>: 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: 48 8b 05 00 00 00 00 mov rax,QWORD PTR [rip+0x0] b: 8b 0d 00 00 00 00 mov ecx,DWORD PTR [rip+0x0] 11: 83 c1 01 add ecx,0x1 14: 89 0d 00 00 00 00 mov DWORD PTR [rip+0x0],ecx 1a: 8b 08 mov ecx,DWORD PTR [rax] 1c: 03 0d 00 00 00 00 add ecx,DWORD PTR [rip+0x0] 22: 89 c8 mov eax,ecx 24: 5d pop rbp 25: c3 ret 

y luego, después de vincular el objeto a la biblioteca dinámica:


 00000000000010e0 <_Z18veryUsefulFunctionv>: 10e0: 55 push rbp 10e1: 48 89 e5 mov rbp,rsp 10e4: 48 8b 05 05 21 00 00 mov rax,QWORD PTR [rip+0x2105] 10eb: 8b 0d 13 2f 00 00 mov ecx,DWORD PTR [rip+0x2f13] 10f1: 83 c1 01 add ecx,0x1 10f4: 89 0d 0a 2f 00 00 mov DWORD PTR [rip+0x2f0a],ecx 10fa: 8b 08 mov ecx,DWORD PTR [rax] 10fc: 03 0d 02 2f 00 00 add ecx,DWORD PTR [rip+0x2f02] 1102: 89 c8 mov eax,ecx 1104: 5d pop rbp 1105: c3 ret 

Hay 4 lugares en los que 4 bytes están reservados para la dirección de variables reales.


En diferentes sistemas, el conjunto de posibles reubicaciones es suyo. En Linux en x86-64, hasta 40 tipos de reubicaciones . Solo hay 9 de ellos en macOS en x86-64. Todos los tipos de reubicaciones se pueden dividir condicionalmente en 2 grupos:


  1. Reubicaciones en tiempo de enlace: reubicaciones utilizadas en el proceso de vincular archivos de objetos a un archivo ejecutable o biblioteca dinámica
  2. Reubicaciones de tiempo de carga: reubicaciones aplicadas en el momento en que la biblioteca dinámica se carga en la memoria del proceso

El segundo grupo incluye reubicaciones de funciones y variables exportadas. Cuando se carga una biblioteca dinámica en la memoria del proceso, para todas las reubicaciones dinámicas (incluidas las reubicaciones de variables globales), el vinculador busca la definición de símbolos en todas las bibliotecas ya cargadas, incluido el propio programa, y ​​la dirección del primer símbolo adecuado se usa para la reubicación. Por lo tanto, no es necesario hacer nada con estas reubicaciones; el vinculador encontrará la variable de nuestra aplicación, ya que entrará en su lista de bibliotecas y programas cargados anteriormente, y sustituirá su dirección en el nuevo código, ignorando la nueva versión de esta variable.


Hay un punto sutil asociado con macOS y su vinculador dinámico. MacOS implementa el llamado mecanismo de espacio de nombres de dos niveles. Si es grosero, al cargar una biblioteca dinámica, el vinculador primero buscará caracteres en esta biblioteca y, si no lo encuentra, buscará en otros. Esto se hace con fines de rendimiento, para que las reubicaciones se resuelvan rápidamente, lo que, en general, es lógico. Pero esto rompe nuestro flujo con respecto a las variables globales. Afortunadamente, en ld en macOS hay un indicador especial: -flat_namespace , y si construye una biblioteca con este indicador, el algoritmo de búsqueda de caracteres será idéntico al de Linux.


El primer grupo incluye reubicaciones de variables estáticas, exactamente lo que necesitamos. El único problema es que estas reubicaciones no están en la biblioteca compilada, ya que el vinculador ya las resolvió. Por lo tanto, los leeremos de los archivos de objetos desde los cuales se ensambló la biblioteca.
Los posibles tipos de reubicaciones también están limitados por si el código ensamblado depende de la posición o no. Dado que recopilamos nuestro código en modo PIC (código independiente de la posición), las reubicaciones se usan solo de forma relativa. Las reubicaciones totales que nos interesan son:


  • Reubicaciones desde la sección .rela.text en Linux y las reubicaciones a las que hace referencia la sección __text en macOS, y
  • Que usa caracteres de las secciones .data y .bss en Linux y __data , __bss y __common en macOS, y
  • Las reubicaciones son de tipo R_X86_64_PC32 y R_X86_64_PC64 en Linux y X86_64_RELOC_SIGNED , X86_64_RELOC_SIGNED_1 , X86_64_RELOC_SIGNED_2 y X86_64_RELOC_SIGNED_4 en macOS

El punto sutil asociado con la sección __common . Linux también tiene una sección similar *COM* . Las variables globales pueden caer en esta sección . Pero, aunque probé y compilé un montón de fragmentos de código, en Linux, las reubicaciones de caracteres de las secciones *COM* siempre fueron dinámicas, como las variables globales regulares. Al mismo tiempo, en macOS, tales caracteres a veces se reubicaban durante la vinculación si la función y el carácter están en el mismo archivo. Por lo tanto, en macOS tiene sentido considerar esta sección al leer caracteres y reubicaciones.


Bueno, ahora tenemos un conjunto de todas las reubicaciones que necesitamos, ¿qué hacer con ellas? La lógica aquí es simple. Cuando el vinculador vincula la biblioteca, escribe la dirección del símbolo calculado por una determinada fórmula en la dirección de reubicación. Para nuestras reubicaciones en ambas plataformas, esta fórmula contiene la dirección del símbolo como un término. Por lo tanto, la dirección calculada ya registrada en el cuerpo de funciones tiene la forma:


 resultAddr = newVarAddr + addend - relocAddr 

Al mismo tiempo, conocemos las direcciones de ambas versiones de variables: antiguas, que ya están en la aplicación y nuevas. Nos queda cambiarlo de acuerdo con la fórmula:


 resultAddr = resultAddr - newVarAddr + oldVarAddr 

y escríbalo a la dirección de reubicación. Después de eso, todas las funciones en el nuevo código usarán las versiones existentes de las variables, y las nuevas variables simplemente mentirán y no harán nada. Lo que necesitas! Pero hay un punto sutil.


Descargando la biblioteca con el nuevo código


Cuando el sistema carga una biblioteca dinámica en la memoria del proceso, es libre de colocarla en cualquier lugar del espacio de direcciones virtuales. En Ubuntu 18.04, mi aplicación se carga en 0x00400000 y nuestras bibliotecas dinámicas inmediatamente después de ld-2.27.so en direcciones en el área 0x7fd3829bd000 . La distancia entre las direcciones de descarga del programa y la biblioteca es mucho mayor que el número que cabría en el entero de 32 bits con signo. Y en las reubicaciones en tiempo de enlace, solo 4 bytes están reservados para direcciones de caracteres de destino.


Después de fumar la documentación para compiladores y enlazadores, decidí probar la opción -mcmodel=large . Obliga al compilador a generar código sin suposiciones sobre la distancia entre los caracteres, por lo que se supone que todas las direcciones son de 64 bits. Pero esta opción no es -mcmodel=large PIC, como si -mcmodel=large no se puede usar con -fPIC , al menos en macOS. Todavía no entiendo cuál es el problema, tal vez en macOS no hay reubicaciones adecuadas para esta situación.


En la biblioteca bajo Windows, este problema se resuelve de la siguiente manera. Las manos asignan un trozo de memoria virtual cerca de la ubicación de descarga de la aplicación, suficiente para acomodar las secciones necesarias de la biblioteca. Luego, las secciones se cargan con las manos, los derechos necesarios se establecen en las páginas de memoria con las secciones correspondientes, todas las reubicaciones se descomprimen con las manos y todo lo demás se repara. Soy vago Realmente no quería hacer todo este trabajo con reubicaciones de tiempo de carga, especialmente en Linux. ¿Y por qué lo que un enlazador dinámico ya sabe hacer? Después de todo, las personas que lo escribieron saben mucho más que yo.


Afortunadamente, la documentación encontró las opciones necesarias para indicar dónde descargar nuestra biblioteca dinámica:


  • Apple ld: -image_base 0xADDRESS
  • LLVM lld: --image-base=0xADDRESS
  • GNU ld: -Ttext-segment=0xADDRESS

Estas opciones deben pasarse al vinculador al momento de vincular la biblioteca dinámica. Hay 2 dificultades.
El primero está relacionado con GNU ld. Para que estas opciones funcionen, debe:


  • En el momento de cargar la biblioteca, el área en la que queremos cargar era libre
  • La dirección especificada en la opción debe ser un múltiplo del tamaño de la página (en Linux x86-64 y macOS es 0x1000 )
  • Al menos en Linux, la dirección especificada en la opción debe ser un múltiplo de la alineación del segmento PT_LOAD

Es decir, si el vinculador establece la alineación en 0x10000000 , entonces esta biblioteca no se puede cargar en la dirección 0x10001000 , aunque la dirección esté alineada con el tamaño de la página. Si no se cumple una de estas condiciones, la biblioteca se cargará "como de costumbre". Tengo GNU ld 2.30 en mi sistema y, a diferencia de LLVM lld, establece de manera predeterminada la alineación del segmento 0x20000 en 0x20000 , que está muy fuera de la imagen. Para evitar esto, además de la -Ttext-segment=... , especifique -z max-page-size=0x1000 . Pasé un día hasta que me di cuenta de por qué la biblioteca no se carga donde necesito.


La segunda dificultad: la dirección de descarga debe conocerse en la etapa de vinculación de la biblioteca. No es muy difícil de organizar. En Linux, es suficiente analizar el pseudoarchivo /proc/<pid>/maps , encontrar la pieza desocupada más cercana al programa, en la que se ajustará la biblioteca, y usar la dirección del comienzo de esta pieza al vincular. El tamaño de la biblioteca futura se puede estimar de manera aproximada observando los tamaños de los archivos de objetos, o analizándolos y calculando los tamaños de todas las secciones. Al final, no necesitamos un número exacto, sino un tamaño aproximado con un margen.


MacOS no tiene /proc/* ; en cambio, se sugiere que use la utilidad vmmap . La salida del vmmap -interleaved <pid> contiene la misma información que proc/<pid>/maps . Pero aquí surge otra dificultad. Si una aplicación crea un proceso secundario que ejecuta este comando, y el identificador del proceso actual se especifica como <pid> , el programa se bloqueará. Según tengo entendido, vmmap detiene el proceso para leer sus asignaciones de memoria y, aparentemente, si este es el proceso de llamada, entonces algo sale mal. En este caso, debe especificar el indicador adicional -forkCorpse para que vmmap cree un proceso hijo vacío de nuestro proceso, elimine el mapeo y elimínelo, sin interrumpir el programa.


Eso es básicamente todo lo que necesitamos saber.


Poniendo todo junto


Con estas modificaciones, el algoritmo de recarga de código final se ve así:


  1. Compila el nuevo código en archivos de objetos
  2. Para archivos de objetos, estimamos el tamaño de la futura biblioteca
  3. Lectura de archivos de objetos de reubicación
  4. Estamos buscando una pieza de memoria virtual gratuita junto a la aplicación.
  5. Construimos una biblioteca dinámica con las opciones necesarias, dlopen través de dlopen
  6. Código de parche según reubicaciones en tiempo de enlace
  7. Función de parche
  8. Copie las variables estáticas que no participaron en el paso 6

Solo las variables de protección de las variables estáticas entran en el paso 8, por lo que pueden copiarse de forma segura (preservando así la "inicialización" de las variables estáticas mismas).


Conclusión


Como se trata exclusivamente de una herramienta de desarrollo, no está destinada a ninguna producción, lo peor que puede suceder si la próxima biblioteca con nuevo código no cabe en la memoria o se carga accidentalmente en una dirección diferente es reiniciar la aplicación depurada. Al ejecutar pruebas, 31 bibliotecas con código actualizado se cargan en la memoria a su vez.


Para completar, faltan 3 piezas más pesadas en la implementación:


  1. Ahora la biblioteca con el nuevo código se carga en la memoria al lado del programa, aunque el código de otra biblioteca dinámica que se ha cargado mucho puede entrar en ella. Para solucionarlo, debe realizar un seguimiento de la propiedad de las unidades de traducción a una u otra biblioteca y programa, y ​​dividir la biblioteca con el nuevo código si es necesario.
  2. La recarga de código en una aplicación multiproceso aún no es confiable (con certeza, solo puede volver a cargar el código que se ejecuta en el mismo hilo que la biblioteca runloop). Para la fijación, es necesario mover parte de la implementación a un programa separado, y este programa, antes de parchear, debe detener el proceso con todos los hilos, parchearlo y volverlo a trabajar. No sé cómo hacer esto sin un programa externo.
  3. Prevención del bloqueo accidental de la aplicación después de la recarga del código. Después de corregir el código, puede desreferenciar accidentalmente el puntero no válido en el nuevo código, después de lo cual deberá reiniciar la aplicación. Nada mal, pero aún así. Suena como magia negra, todavía estoy en el pensamiento.

Pero ya la implementación actual comenzó a beneficiarme personalmente, es suficiente para usar en mi trabajo principal. Cuesta un poco acostumbrarse, pero el vuelo es normal.
Si llego a estos tres puntos y encuentro en su implementación una cantidad suficiente de cosas interesantes, definitivamente lo compartiré.


Demo


Dado que la implementación le permite agregar nuevas unidades de transmisión sobre la marcha, decidí grabar un video corto en el que escribo un simple juego obsceno desde cero sobre una nave espacial que ara las extensiones del universo y dispara asteroides cuadrados. Traté de no escribir en el estilo de "todo en un archivo", pero, si es posible, organizando todo en los estantes, generando así muchos archivos pequeños (por lo tanto, salieron tantos garabatos). Por supuesto, el marco se utiliza para dibujar, entradas, ventanas y otras cosas, pero el código del juego en sí fue escrito desde cero.
La característica principal: solo ejecuté la aplicación 3 veces: al principio, cuando solo tenía una escena vacía, y 2 veces después de la caída debido a mi negligencia. Todo el juego se vierte gradualmente en el proceso de escribir código. Tiempo real: unos 40 minutos. En general, de nada.



Como siempre, estaré encantado de cualquier crítica, ¡gracias!


Enlace a la implementación

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


All Articles