Implémentation d'un rechargement à chaud du code C ++ sur Linux et macOS: creuser plus profondément


* Lien vers la bibliothèque et la vidéo de démonstration à la fin de l'article. Pour comprendre ce qui se passe et qui sont toutes ces personnes, je vous recommande de lire l' article précédent .


Dans le dernier article, nous nous sommes familiarisés avec une approche qui permet un rechargement à chaud du code c ++. Dans ce cas, le "code" désigne les fonctions, les données et leur travail coordonné les uns avec les autres. Il n'y a pas de problèmes particuliers avec les fonctions, nous redirigeons le flux d'exécution de l'ancienne fonction vers la nouvelle, et tout fonctionne. Le problème se pose avec les données (variables statiques et globales), à savoir avec la stratégie de leur synchronisation dans l'ancien et le nouveau code. Dans la première implémentation, cette stratégie était très maladroite: nous copions simplement les valeurs de toutes les variables statiques de l'ancien code vers la nouvelle, de sorte que le nouveau code, se référant aux nouvelles variables, fonctionne avec les valeurs de l'ancien code. Bien sûr, ceci est incorrect, et aujourd'hui nous allons essayer de corriger cette faille en résolvant simultanément un certain nombre de problèmes petits mais intéressants.


L'article omet les détails concernant le travail mécanique, tels que la lecture des caractères et les déplacements des fichiers elf et mach-o. L'accent est mis sur les points subtils que j'ai rencontrés au cours du processus de mise en œuvre, et qui peuvent être utiles à quelqu'un qui, comme moi récemment, cherche des réponses.


Essence


Imaginons que nous ayons une classe (exemples synthétiques, ne cherchez pas de sens en eux, seul le code est important):


// 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; } 

Rien de spécial mais une variable statique. Imaginez maintenant que nous voulons changer la méthode printDescription() en:


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

Que se passe-t-il après le rechargement du code? En plus des méthodes de la classe Entity , la variable statique m_livingEntitiesCount également dans la bibliothèque avec le nouveau code. Rien de mauvais ne se produira si nous copions simplement la valeur de cette variable de l'ancien code vers la nouvelle et continuons à utiliser la nouvelle variable, en oubliant l'ancienne, car toutes les méthodes qui utilisent directement cette variable sont dans la bibliothèque avec le nouveau code.


C ++ est très flexible et riche. Et tandis que l'élégance de résoudre certains problèmes en c ++ frise le code nauséabond, j'aime ce langage. Par exemple, imaginez que votre projet n'utilise pas rtti. Dans le même temps, vous devez avoir une implémentation de la classe Any avec une interface quelque peu sécurisée:


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

Nous n'entrerons pas dans les détails de l'implémentation de cette classe. Ce qui est important pour nous, c'est que pour l'implémentation, nous avons besoin d'une sorte de mécanisme pour le mappage sans ambiguïté du type (entité au moment de la compilation) dans la valeur d'une variable, par exemple, uint64_t (entité d'exécution), c'est-à-dire les types "énumérés". Lors de l'utilisation de rtti, des choses comme type_info et, plus appropriées pour nous, type_index sont à notre disposition. Mais nous n'avons pas de rtti. Dans ce cas, un hack assez courant (ou une solution élégante?) Est-ce que cette fonction:


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

L'implémentation de la classe Any ressemblera alors à ceci:


 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; }; 

Pour chaque type, la fonction sera instanciée exactement 1 fois, respectivement, chaque version de la fonction aura sa propre variable statique, évidemment avec sa propre adresse unique. Que se passe-t-il lorsque nous rechargeons le code à l'aide de cette fonction? Les appels vers l'ancienne version de la fonction seront redirigés vers la nouvelle. La nouvelle aura sa propre variable statique déjà initialisée (nous avons copié la variable value et guard). Mais nous ne sommes pas intéressés par le sens, nous utilisons uniquement l'adresse. Et l'adresse de la nouvelle variable sera différente. Ainsi, les données sont devenues incohérentes: dans les instances déjà créées de la classe Any , l'adresse de l'ancienne variable statique sera stockée, et la méthode is() comparera avec l'adresse de la nouvelle, et "cet Any ne Any plus le même Any " ©.


Plan


Pour résoudre ce problème, vous avez besoin de quelque chose de plus intelligent que la simple copie. Après avoir passé quelques soirées sur Google, lu de la documentation, des codes sources et des API système, le plan suivant a été construit dans ma tête:


  1. Après avoir construit le nouveau code, nous passons par les délocalisations .
  2. De ces délocalisations, nous obtenons tous les emplacements du code qui utilisent des variables statiques (et parfois globales).
  3. Au lieu d'adresses vers de nouvelles versions de variables, nous substituons les adresses des anciennes versions au lieu de relocalisation.

Dans ce cas, il n'y aura aucun lien vers de nouvelles données, l'application entière continuera de fonctionner avec les anciennes versions des variables jusqu'à l'adresse. Ça devrait marcher. Cela ne peut manquer de fonctionner.


Délocalisations


Lorsque le compilateur génère du code machine, il insère plusieurs octets suffisants pour écrire l'adresse réelle de la variable ou de la fonction à cet endroit à chaque endroit où la fonction est appelée ou l'adresse de la variable est chargée, et génère également une relocalisation. Il ne peut pas enregistrer immédiatement l'adresse réelle, car à ce stade, il ne connaît pas cette adresse. Les fonctions et les variables après la liaison peuvent être dans différentes sections, à différents endroits des sections, dans les sections finales peuvent être chargées à différentes adresses au moment de l'exécution.


La réinstallation contient des informations:


  • À quelle adresse devez-vous écrire l'adresse de la fonction ou de la variable
  • L'adresse de la fonction ou de la variable à écrire
  • La formule par laquelle cette adresse doit être calculée
  • Combien d'octets sont réservés pour cette adresse

Dans différents OS, les délocalisations sont représentées différemment, mais au final, elles fonctionnent toutes sur le même principe. Par exemple, dans elf (Linux), les relocalisations sont situées dans des sections spéciales .rela (dans la version 32 bits, c'est .rel ), qui se réfèrent à la section avec l'adresse qui doit être corrigée (par exemple, .rela.text - la section dans laquelle les délocalisations sont situées, appliqué à la section .text ), et chaque entrée stocke des informations sur le symbole dont vous souhaitez insérer l'adresse dans le site de relocalisation. Dans mach-o (macOS), le contraire est le cas; il n'y a pas de section distincte pour les délocalisations; à la place, chaque section contient un pointeur vers une table de délocalisations qui doit être appliquée à cette section, et chaque enregistrement de cette table a une référence à un symbole relationnel.
Par exemple, pour un tel code (avec l'option -fPIC ):


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

le compilateur créera une telle section avec des délocalisations sous 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 

et une telle table de relocalisation sur 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 

Et voici la fonction veryUsefulFunction() (sous 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 

et ainsi après avoir lié l'objet à la bibliothèque dynamique:


 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 

Il y a 4 emplacements dans lesquels 4 octets sont réservés pour l'adresse des variables réelles.


Sur différents systèmes, l'ensemble des délocalisations possibles est le vôtre. Sous Linux sur x86-64, jusqu'à 40 types de délocalisations . Il n'y en a que 9 sur macOS sur x86-64. Tous les types de délocalisations peuvent être conditionnellement divisés en 2 groupes:


  1. Relocations au moment de la liaison - relocalisations utilisées dans le processus de liaison de fichiers d'objets à un fichier exécutable ou à une bibliothèque dynamique
  2. Délocalisations au moment du chargement - délocalisations appliquées au moment du chargement de la bibliothèque dynamique dans la mémoire de processus

Le deuxième groupe comprend les relocalisations des fonctions et variables exportées. Lorsqu'une bibliothèque dynamique est chargée dans la mémoire de processus, pour toutes les relocalisations dynamiques (y compris les relocalisations de variables globales), l'éditeur de liens recherche la définition des symboles dans toutes les bibliothèques déjà chargées, y compris dans le programme lui-même, et l'adresse du premier symbole approprié est utilisée pour la relocalisation. Ainsi, rien ne doit être fait avec ces délocalisations; l'éditeur de liens trouvera la variable dans notre application elle-même, car elle tombera dans sa liste de bibliothèques et de programmes chargés plus tôt, et remplacera son adresse dans le nouveau code, ignorant la nouvelle version de cette variable.


Il y a un point subtil associé à macOS et à son éditeur de liens dynamique. MacOS implémente le mécanisme de l'espace de noms dit à deux niveaux. Si c'est grossier, alors lors du chargement d'une bibliothèque dynamique, l'éditeur de liens recherchera d'abord les personnages de cette bibliothèque, et s'il ne le trouve pas, il cherchera dans les autres. Cela se fait à des fins de performances, de sorte que les délocalisations se résolvent rapidement, ce qui, en général, est logique. Mais cela rompt notre flux concernant les variables globales. Heureusement, ld sur macOS a un drapeau spécial - -flat_namespace , et si vous construisez une bibliothèque avec ce drapeau, l'algorithme de recherche de caractères sera identique à celui de Linux.


Le premier groupe comprend les relocalisations de variables statiques - exactement ce dont nous avons besoin. Le seul problème est que ces délocalisations ne sont pas dans la bibliothèque compilée, car elles sont déjà résolues par l'éditeur de liens. Par conséquent, nous les lirons à partir des fichiers objets à partir desquels la bibliothèque a été assemblée.
Les types de délocalisations possibles sont également limités selon que le code assemblé dépend de la position ou non. Puisque nous collectons notre code en mode PIC (code indépendant de la position), les délocalisations ne sont utilisées que de manière relative. Les délocalisations totales qui nous intéressent sont:


  • .rela.text depuis la section .rela.text sous Linux et les déplacements référencés par la section __text sous macOS, et
  • Qui utilise des caractères des sections .data et .bss sous Linux et __data , __bss et __common sous macOS, et
  • Les délocalisations sont de type R_X86_64_PC32 et R_X86_64_PC64 sur Linux et X86_64_RELOC_SIGNED , X86_64_RELOC_SIGNED_1 , X86_64_RELOC_SIGNED_2 et X86_64_RELOC_SIGNED_4 sur macOS

Le point subtil associé à la section __common . Linux a également une section *COM* similaire. Les variables globales peuvent tomber dans cette section . Mais, pendant que je testais et compilais un tas d'extraits de code, sous Linux, les relocalisations de caractères des sections *COM* étaient toujours dynamiques, comme les variables globales régulières. Dans le même temps, sur macOS, ces caractères étaient parfois déplacés lors de la liaison si la fonction et le personnage se trouvaient dans le même fichier. Par conséquent, sur macOS, il est logique de prendre en compte cette section lors de la lecture des caractères et des relocalisations.


Eh bien, nous avons maintenant un ensemble de toutes les délocalisations dont nous avons besoin, que faire avec eux? La logique ici est simple. Lorsque l'éditeur de liens relie la bibliothèque, il écrit l'adresse du symbole calculée par une certaine formule à l'adresse de relocalisation. Pour nos délocalisations sur les deux plateformes, cette formule contient l'adresse du symbole en tant que terme. Ainsi, l'adresse calculée déjà enregistrée dans le corps des fonctions a la forme:


 resultAddr = newVarAddr + addend - relocAddr 

Dans le même temps, nous connaissons les adresses des deux versions de variables - anciennes, déjà présentes dans l'application et nouvelles. Il nous reste à le changer selon la formule:


 resultAddr = resultAddr - newVarAddr + oldVarAddr 

et l'écrire à l'adresse de réinstallation. Après cela, toutes les fonctions du nouveau code utiliseront les versions existantes des variables, et les nouvelles variables resteront simplement et ne feront rien. Ce dont vous avez besoin! Mais il y a un point subtil.


Téléchargement de la bibliothèque avec le nouveau code


Lorsque le système charge une bibliothèque dynamique dans la mémoire de processus, il est libre de la placer n'importe où dans l'espace d'adressage virtuel. Sur mon Ubuntu 18.04, l'application est chargée à 0x00400000 , et nos bibliothèques dynamiques juste après ld-2.27.so aux adresses dans la zone 0x7fd3829bd000 . La distance entre les adresses de téléchargement du programme et de la bibliothèque est beaucoup plus grande que le nombre qui tiendrait dans l'entier 32 bits signé. Et dans les délocalisations au moment de la liaison, seuls 4 octets sont réservés aux adresses des caractères cibles.


Après avoir fumé la documentation des compilateurs et des -mcmodel=large liens, j'ai décidé d'essayer l'option -mcmodel=large . Il oblige le compilateur à générer du code sans aucune hypothèse sur la distance entre les caractères, ainsi toutes les adresses sont supposées être 64 bits. Mais cette option n'est pas compatible PIC, car si -mcmodel=large ne peut pas être utilisé avec -fPIC , au moins sur macOS. Je ne comprends toujours pas quel est le problème, peut-être que sur macOS il n'y a pas de relocalisation appropriée pour cette situation.


Dans la bibliothèque sous Windows, ce problème est résolu comme suit. Les mains allouent un morceau de mémoire virtuelle près de l'emplacement de téléchargement de l'application, suffisant pour accueillir les sections nécessaires de la bibliothèque. Ensuite, les sections y sont chargées avec les mains, les droits nécessaires sont définis sur les pages de mémoire avec les sections correspondantes, toutes les relocalisations sont décompressées à la main et tout le reste est corrigé. Je suis paresseux. Je ne voulais vraiment pas faire tout ce travail avec des délocalisations de temps de chargement, en particulier sous Linux. Et pourquoi faire ce qu'un éditeur de liens dynamique sait déjà faire? Après tout, les gens qui l'ont écrit en savent beaucoup plus que moi.


Heureusement, la documentation a trouvé les options nécessaires pour indiquer où télécharger notre bibliothèque dynamique:


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

Ces options doivent être transmises à l'éditeur de liens au moment de la liaison de la bibliothèque dynamique. Il y a 2 difficultés.
Le premier est lié à GNU ld. Pour que ces options fonctionnent, vous devez:


  • Au moment du chargement de la bibliothèque, la zone dans laquelle nous voulons la charger était libre
  • L'adresse spécifiée dans l'option doit être un multiple de la taille de la page (sur x86-64 Linux et macOS c'est 0x1000 )
  • Au moins sous Linux, l'adresse spécifiée dans l'option doit être un multiple de l'alignement du segment PT_LOAD

Autrement dit, si l'éditeur de liens définit l'alignement sur 0x10000000 , cette bibliothèque ne peut pas être chargée à l'adresse 0x10001000 , même si l'adresse est alignée sur la taille de la page. Si l'une de ces conditions n'est pas remplie, la bibliothèque se charge «comme d'habitude». J'ai GNU ld 2.30 sur mon système et, contrairement à LLVM lld, par défaut, il définit l'alignement du segment 0x20000 sur 0x20000 , ce qui est très absent. Pour contourner ce -Ttext-segment=... , en plus de l' -Ttext-segment=... , spécifiez -z max-page-size=0x1000 . J'ai passé une journée jusqu'à ce que je réalise pourquoi la bibliothèque ne se charge pas là où je dois.


La deuxième difficulté - l'adresse de téléchargement doit être connue au stade de la liaison de la bibliothèque. Ce n'est pas très difficile à organiser. Sous Linux, il suffit d'analyser le pseudo-fichier /proc/<pid>/maps , de trouver la pièce inoccupée la plus proche du programme, dans laquelle la bibliothèque s'insérera, et d'utiliser l'adresse du début de cette pièce lors de la liaison. La taille de la future bibliothèque peut être estimée approximativement en regardant les tailles des fichiers objets, ou en les analysant et en calculant les tailles de toutes les sections. Au final, nous n'avons pas besoin d'un nombre exact, mais d'une taille approximative avec une marge.


MacOS n'a pas /proc/* ; à la place, il est suggéré d'utiliser l'utilitaire vmmap . La sortie de la commande vmmap -interleaved <pid> contient les mêmes informations que proc/<pid>/maps . Mais ici se pose une autre difficulté. Si une application crée un processus enfant qui exécute cette commande et que l'identificateur du processus en cours est spécifié comme <pid> , le programme se bloquera. Si je comprends bien, vmmap arrête le processus pour lire ses mappages de mémoire, et apparemment, si c'est le processus d'appel, alors quelque chose se passe mal. Dans ce cas, vous devez spécifier l'indicateur supplémentaire -forkCorpse pour que vmmap crée un processus enfant vide de notre processus, supprimez le mappage et tuez-le, n'interrompant ainsi pas le programme.


C'est essentiellement tout ce que nous devons savoir.


Tout mettre ensemble


Avec ces modifications, l'algorithme de rechargement de code final ressemble à ceci:


  1. Compilez le nouveau code dans des fichiers objets
  2. Pour les fichiers objets, nous estimons la taille de la future bibliothèque
  3. Lecture des fichiers d'objets de relocalisation
  4. Nous recherchons un morceau de mémoire virtuelle gratuit à côté de l'application
  5. Nous construisons une bibliothèque dynamique avec les options nécessaires, dlopen via dlopen
  6. Code de patch en fonction des délocalisations de temps de liaison
  7. Fonction Patch
  8. Copiez les variables statiques qui n'ont pas participé à l'étape 6

Seules les variables de garde des variables statiques entrent dans l'étape 8, afin qu'elles puissent être copiées en toute sécurité (préservant ainsi "l'initialisation" des variables statiques elles-mêmes).


Conclusion


Comme il s'agit exclusivement d'un outil de développement, qui n'est destiné à aucune production, la pire chose qui puisse arriver si la prochaine bibliothèque avec le nouveau code ne tient pas en mémoire ou se charge accidentellement à une adresse différente est un redémarrage de l'application déboguée. Lors de l'exécution des tests, 31 bibliothèques avec du code mis à jour sont chargées à tour de rôle dans la mémoire.


Pour être complet, il manque 3 pièces supplémentaires de poids dans la mise en œuvre:


  1. Maintenant, la bibliothèque avec le nouveau code est chargée dans la mémoire à côté du programme, bien que le code d'une autre bibliothèque dynamique qui a été chargée loin puisse y entrer. Pour résoudre ce problème, vous devez suivre la propriété des unités de traduction dans l'une ou l'autre bibliothèque et programme, et diviser la bibliothèque avec le nouveau code si nécessaire.
  2. Le rechargement du code dans une application multithread n'est toujours pas fiable (avec certitude, vous ne pouvez recharger que du code qui s'exécute dans le même thread que la bibliothèque runloop). Pour la fixation, il est nécessaire de déplacer une partie de l'implémentation dans un programme distinct, et ce programme, avant de patcher, doit arrêter le processus avec tous les threads, patcher et le remettre au travail. Je ne sais pas comment faire cela sans programme externe.
  3. Prévention du plantage accidentel de l'application après le rechargement du code. Après avoir corrigé le code, vous pouvez accidentellement déréférencer le pointeur non valide dans le nouveau code, après quoi vous devrez redémarrer l'application. Rien de mal, mais quand même. Cela ressemble à de la magie noire, je pense toujours.

Mais déjà la mise en œuvre actuelle a commencé à me bénéficier personnellement, elle suffit pour une utilisation dans mon travail principal. Il faut un peu de temps pour s'y habituer, mais le vol est normal.
Si j'arrive à ces trois points et que je trouve dans leur mise en œuvre un nombre suffisant de choses intéressantes, je le partagerai certainement.


Démo


Étant donné que la mise en œuvre permet d'ajouter de nouvelles unités de diffusion à la volée, j'ai décidé d'enregistrer une courte vidéo dans laquelle j'écris un jeu obscène simple à partir de zéro sur un vaisseau spatial labourant les étendues de l'univers et tirant des astéroïdes carrés. J'ai essayé de ne pas écrire dans le style de "tout en un fichier", mais, si possible, de tout organiser sur les étagères, générant ainsi de nombreux petits fichiers (donc, tellement de gribouillis sont sortis). Bien sûr, le cadre est utilisé pour le dessin, les entrées, les fenêtres et autres choses, mais le code du jeu lui-même a été écrit à partir de zéro.
La principale caractéristique - je n'ai exécuté l'application que 3 fois: au tout début, alors qu'elle n'avait qu'une scène vide, et 2 fois après la chute à cause de ma négligence. L'ensemble du jeu s'est déversé progressivement dans le processus d'écriture du code. Temps réel - environ 40 minutes. En général, vous êtes les bienvenus.



Comme toujours, je serai heureux de toute critique, merci!


Lien avec la mise en œuvre

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


All Articles