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