
* Lien vers la bibliothèque à la fin de l'article. L'article lui-même décrit les mécanismes mis en œuvre dans la bibliothèque, avec des détails moyens. L'implémentation pour macOS n'est pas encore terminée, mais elle n'est pas très différente de l'implémentation pour Linux. Il s'agit principalement d'une implémentation pour Linux.
En parcourant le github un samedi après-midi, je suis tombé sur une bibliothèque qui implémente la mise à jour du code c ++ à la volée pour Windows. Je suis moi-même descendu de Windows il y a quelques années, je ne l'ai pas regretté un peu, et maintenant toute la programmation se fait soit sur Linux (à la maison) soit sur macOS (au travail). Googler un peu, j'ai trouvé que l'approche de la bibliothèque ci-dessus est assez populaire, et msvc utilise la même technique pour la fonction "Modifier et continuer" dans Visual Studio. Le seul problème est que je n'ai trouvé aucune implémentation sous non-windows (ai-je mal regardé?). À la question à l'auteur de la bibliothèque ci-dessus s'il fera un portage pour d'autres plateformes, la réponse était non.
Je dois dire tout de suite que je n'étais intéressé que par l'option dans laquelle je n'aurais pas à modifier le code de projet existant (comme, par exemple, dans le cas de RCCPP ou cr , où tout le code potentiellement rechargé devrait être dans une bibliothèque séparée chargée dynamiquement).
"Comment ça?" - J'ai pensé, et j'ai commencé à allumer de l'encens.
Pourquoi?
Je fais principalement du gamedev. La plupart de mon temps de travail est consacré à l'écriture de la logique du jeu et à la disposition de tout visuel. J'utilise également imgui pour les utilitaires d'assistance. Mon cycle de travail avec le code, comme vous l'avez probablement deviné, est Écrire -> Compiler -> Exécuter -> Répéter. Tout se passe assez rapidement (build incrémental, toutes sortes de ccache, etc.). Le problème ici est que ce cycle doit être répété assez souvent. Par exemple, j'écris une nouvelle mécanique de jeu, que ce soit "Jump", un Jump valide et contrôlé:
1. Rédaction d'un projet de mise en œuvre basé sur l'élan, assemblé, lancé. J'ai vu que j'applique accidentellement une impulsion à chaque image, et pas une seule fois.
2. Fixé, assemblé, lancé, maintenant normal. Mais il faudrait prendre davantage la valeur absolue de l'impulsion.
3. Fixé, assemblé, lancé, fonctionnant. Mais d'une manière ou d'une autre, cela ne va pas. Nous devons essayer de le faire sur la base de la force.
4. Rédaction d'un projet de mise en œuvre basé sur la force, assemblé, lancé, travaux. Il suffirait seulement de changer la vitesse instantanée au moment du saut.
...
10. Fixé, assemblé, lancé, fonctionnant. Mais toujours pas ça. Il faudra probablement essayer une implémentation basée sur un changement de gravityScale
.
...
20. Super, ça a l'air super! Maintenant, nous retirons tous les paramètres dans l'éditeur pour le gamediz, testons et remplissons.
...
30. Le saut est prêt.
Et à chaque itération, vous devez collecter le code et dans l'application lancée arriver à l'endroit où je peux sauter. Cela prend généralement au moins 10 secondes. Et si je ne peux sauter que dans une zone ouverte, laquelle doit encore être atteinte? Et si j'ai besoin de pouvoir sauter sur des blocs d'une hauteur de N unités? Ici, j'ai déjà besoin de collecter une scène de test, qui doit également être déboguée et qui doit également passer du temps. C'est pour de telles itérations qu'un rechargement à chaud de code serait idéal. Bien sûr, ce n'est pas une panacée, elle ne convient pas à tout, et après un redémarrage, vous devez parfois recréer une partie du monde du jeu, et cela doit être pris en compte. Mais à bien des égards, cela peut être utile et peut économiser de l'attention et beaucoup de temps.
Exigences et énoncé du problème
- Lors de la modification du code, la nouvelle version de toutes les fonctions doit remplacer les anciennes versions des mêmes fonctions
- Cela devrait fonctionner sur Linux et macOS
- Cela ne devrait pas nécessiter de modifications du code d'application existant.
- Idéalement, il devrait s'agir d'une bibliothèque, liée statiquement ou dynamiquement à l'application, sans utilitaires tiers
- Il est souhaitable que cette bibliothèque n'affecte pas beaucoup les performances de l'application.
- Assez si cela fonctionne avec cmake + make / ninja
- Cela suffit si cela fonctionne avec les versions de Debazine (sans optimisations, sans découpage des caractères, etc.)
Il s'agit de l'ensemble minimal d'exigences qu'une implémentation doit satisfaire. À l'avenir, je décrirai brièvement ce qui a également été mis en œuvre:
- Transfert de valeurs de variables statiques dans un nouveau code (voir la section "Transfert de variables statiques" pour savoir pourquoi cela est important)
- Rechargement basé sur les dépendances (en-tête modifié -> reconstruit
un demi-projet tous les fichiers dépendants) - Rechargement de code à partir de bibliothèques dynamiques
Implémentation
Jusqu'à ce moment, j'étais complètement loin du sujet, donc j'ai dû collecter et assimiler des informations à partir de zéro.
À un niveau élevé, le mécanisme ressemble à ceci:
- Nous surveillons le système de fichiers pour les changements dans la source
- Lorsque la source change, la bibliothèque la reconstruit à l'aide de la commande de compilation que ce fichier a déjà été compilé
- Tous les objets collectés sont liés à une bibliothèque chargée dynamiquement
- La bibliothèque est chargée dans l'espace d'adressage du processus
- Toutes les fonctions de la bibliothèque remplacent les mêmes fonctions dans l'application.
- Les valeurs des variables statiques sont transférées de l'application vers la bibliothèque
Commençons par le plus intéressant - le mécanisme de rechargement des fonctions.
Fonctions de rechargement
Voici 3 façons plus ou moins populaires de remplacer des fonctions dans (ou presque) l'exécution:
- Astuce avec LD_PRELOAD - vous permet de construire une bibliothèque chargée dynamiquement avec, par exemple, la fonction
strcpy
, et de faire en sorte que lorsque vous démarrez l'application prend ma version de strcpy
au lieu de la bibliothèque - Changer les tables PLT et GOT - vous permet de "surcharger" les fonctions exportées
- Accrochage des fonctions - vous permet de rediriger le thread d'exécution d'une fonction à une autre
Les 2 premières options, évidemment, ne conviennent pas, car elles ne fonctionnent qu'avec des fonctions exportées, et nous ne voulons pas marquer toutes les fonctions de notre application avec des attributs. Par conséquent, le crochet de fonction est notre option!
En bref, le hook fonctionne comme ceci:
- L'adresse de la fonction est trouvée
- Les premiers octets de la fonction sont remplacés par une transition inconditionnelle vers le corps d'une autre fonction
- ...
- Profit!
Dans msvc, il y a 2 drapeaux pour cela - /hotpatch
et /FUNCTIONPADMIN
. Le premier au début de chaque fonction écrit 2 octets, qui ne font rien, pour leur réécriture ultérieure avec un "saut court". La seconde vous permet de laisser un espace vide devant le corps de chaque fonction sous la forme d'instructions nop
pour un "saut en longueur" à l'emplacement souhaité, donc en 2 sauts, vous pouvez passer de l'ancienne fonction à la nouvelle. Vous pouvez en savoir plus sur la façon dont cela est implémenté dans Windows et MSVC, par exemple, ici .
Malheureusement, il n'y a rien de similaire dans clang et gcc (au moins sous Linux et macOS). En fait, ce n'est pas un gros problème, nous écrirons directement au-dessus de l'ancienne fonction. Dans ce cas, nous risquons de rencontrer des problèmes si notre application est multithread. Si généralement dans un environnement multi-thread nous restreignons l'accès aux données par un thread alors qu'un autre thread les modifie, alors nous devons limiter la possibilité d'exécuter du code par un thread tandis qu'un autre thread modifie ce code. Je n'ai pas compris comment faire cela, donc l'implémentation se comportera de manière imprévisible dans un environnement multi-thread.
Il y a un point subtil. Sur un système 32 bits, 5 octets nous suffisent pour "sauter" n'importe où. Sur un système 64 bits, si nous ne voulons pas gâcher les registres, nous avons besoin de 14 octets. L'essentiel est que 14 octets dans l'échelle du code machine, c'est beaucoup, et si le code a une fonction de stub avec un corps vide, il aura probablement moins de 14 octets de longueur. Je ne connais pas toute la vérité, mais j'ai passé quelque temps derrière le désassembleur en réfléchissant, en écrivant et en déboguant le code, et j'ai remarqué que toutes les fonctions sont alignées sur une limite de 16 octets (débogage sans optimisations, pas sûr du code optimisé). Et cela signifie qu'entre le début de deux fonctions, il y aura au moins 16 octets, ce qui nous suffit de les «brouiller». La recherche Google superficielle a conduit ici , cependant, je ne sais pas avec certitude, j'ai juste eu de la chance, ou aujourd'hui tous les compilateurs le font. Dans tous les cas, en cas de doute, déclarez simplement quelques variables au début de la fonction stub pour qu'elle devienne suffisamment grande.
Nous avons donc le premier grain - un mécanisme pour rediriger les fonctions de l'ancienne version vers la nouvelle.
Rechercher des fonctions dans un programme copié
Maintenant, nous devons en quelque sorte obtenir les adresses de toutes les fonctions (non seulement exportées) de notre programme ou d'une bibliothèque dynamique arbitraire. Cela peut être fait tout simplement en utilisant l'API système si les caractères ne sont pas supprimés de votre application. Sous Linux, ce sont les api d' elf.h
et link.h
, sur macOS, loader.h
et nlist.h
.
- En utilisant
dl_iterate_phdr
nous dl_iterate_phdr
toutes les bibliothèques chargées et, en fait, le programme - Trouvez l'adresse où la bibliothèque est chargée
- De la section
.symtab
, .symtab
obtenons toutes les informations sur les caractères, à savoir le nom, le type, l'index de la section dans laquelle il se trouve, la taille, et calculons également son "vraie" adresse en fonction de l'adresse virtuelle et de l'adresse de chargement de la bibliothèque
Il y a une subtilité. Lors du téléchargement d'un fichier elf, le système ne charge pas la section .symtab
(correcte si elle est incorrecte) et la section .dynsym
ne nous convient pas, car nous ne pouvons pas en extraire des caractères avec la visibilité STV_INTERNAL
et STV_HIDDEN
. Autrement dit, nous ne verrons pas de telles fonctions:
et ces variables:
Ainsi, au paragraphe 3, nous ne travaillons pas avec le programme que dl_iterate_phdr
donné, mais avec le fichier que nous avons téléchargé à partir du disque et analysé par un analyseur elfe (ou sur l'api nu). Donc, nous ne manquons de rien. Sur macOS, la procédure est similaire, seuls les noms des fonctions de l'api système sont différents.
Après cela, nous filtrons tous les caractères et enregistrons uniquement:
- Les fonctions qui peuvent être rechargées sont des caractères de type
STT_FUNC
situés dans la section .text
, qui sont de taille non nulle. Un tel filtre ignore uniquement les fonctions dont le code est réellement contenu dans ce programme ou cette bibliothèque - Les variables statiques dont vous souhaitez transférer les valeurs sont des caractères de type
STT_OBJECT
situés dans la section .bss
Unités de diffusion
Pour recharger le code, nous devons savoir où obtenir les fichiers de code source et comment les compiler.
Dans la première implémentation, j'ai lu ces informations dans la section .debug_info
, qui contient des informations de débogage au format DWARF. Pour que chaque unité de compilation (ET) dans DWARF obtienne une ligne de compilation pour cet ET, vous devez passer -grecord-gcc-switches
pendant la compilation. DWARF lui-même, j'ai analysé la bibliothèque libdwarf, qui est livrée avec libelf
. En plus de la commande de compilation de DWARF, vous pouvez obtenir des informations sur les dépendances de nos ET sur d'autres fichiers. Mais j'ai refusé cette mise en œuvre pour plusieurs raisons:
- Les bibliothèques sont assez lourdes
- L'analyse d'une application DWARF compilée à partir de ~ 500 ET, avec analyse des dépendances, a pris un peu plus de 10 secondes
10 secondes pour démarrer l'application, c'est trop. Après réflexion, j'ai réécrit la logique de l'analyse de DWARF en analysant compile_commands.json
. Ce fichier peut être généré simplement en ajoutant set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
à votre CMakeLists.txt. Ainsi, nous obtenons toutes les informations dont nous avons besoin.
Gestion des dépendances
Depuis que nous avons abandonné DWARF, nous devons trouver une autre option, comment gérer les dépendances entre les fichiers. Je ne voulais vraiment pas analyser les fichiers avec mes mains et y chercher des include
, et qui en sait plus sur les dépendances que le compilateur lui-même?
Il existe un certain nombre d'options dans clang et gcc qui génèrent ce que l'on appelle des fichiers de dépôt presque gratuitement. Ces fichiers utilisent les systèmes de génération make et ninja pour résoudre les dépendances entre les fichiers. Les fichiers de dépôt ont un format très 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 \ ...
Le compilateur place ces fichiers à côté des fichiers objets pour chaque ET, il nous reste à les analyser et à les mettre dans une table de hachage. L'analyse totale compile_commands.json
+ depfiles pour les mêmes 500 ET prend un peu plus d'une seconde. Pour que tout fonctionne, nous devons ajouter l'indicateur -MD
globalement pour tous les fichiers de projet dans l'option de compilation.
Il y a une subtilité associée au ninja. Ce système de génération génère des fichiers de dépôt indépendamment de la présence de l'indicateur -MD
pour leurs besoins. Mais une fois générés, il les traduit dans son format binaire et supprime les fichiers source. Par conséquent, lorsque vous démarrez ninja, vous devez passer l' -d keepdepfile
. De plus, pour des raisons que je ne -MD
pas, dans le cas de make (avec l'option -MD
), le fichier s'appelle some_file.cpp.d
, tandis qu'avec ninja il s'appelle some_file.cpp.od
. Par conséquent, vous devez vérifier les deux versions.
Transfert variable statique
Supposons que nous ayons un tel code (un exemple très synthétique):
Nous voulons changer la fonction veryUsefulFunction
en ceci:
int veryUsefulFunction(int value) { return value * 3; }
Lors du rechargement, dans la bibliothèque dynamique avec du nouveau code, en plus de veryUsefulFunction
, la variable static Singleton ins;
et la méthode Singletor::instance
. Par conséquent, le programme commencera à appeler de nouvelles versions des deux fonctions. Mais les ins
statiques de cette bibliothèque ne sont pas encore initialisées et, par conséquent, la première fois que vous y accéderez, le constructeur de la classe Singleton
sera appelé. Bien sûr, nous ne voulons pas cela. Par conséquent, l'implémentation transfère les valeurs de toutes ces variables qu'elle trouve dans la bibliothèque dynamique assemblée de l'ancien code vers cette bibliothèque très dynamique avec le nouveau code avec leurs variables de garde .
Il y a un moment subtil et généralement insoluble.
Supposons que nous ayons une classe:
class SomeClass { public: void calledEachUpdate() { m_someVar1++; } private: int m_someVar1 = 0; };
La méthode calledEachUpdate
appelée 60 fois par seconde. Nous le changeons en ajoutant un nouveau champ:
class SomeClass { public: void calledEachUpdate() { m_someVar1++; m_someVar2++; } private: int m_someVar1 = 0; int m_someVar2 = 0; };
Si une instance de cette classe se trouve dans la mémoire dynamique ou sur la pile, après avoir rechargé le code, l'application risque de se bloquer. L'instance allouée ne contient que la variable m_someVar1
, mais après le redémarrage, la méthode calledEachUpdate
tentera de modifier m_someVar2
, modifiant ce qui n'appartient pas réellement à cette instance, ce qui entraîne des conséquences imprévisibles. Dans ce cas, la logique de transfert d'état est transférée au programmeur, qui doit d'une manière ou d'une autre sauvegarder l'état de l'objet et supprimer l'objet lui-même avant le rechargement du code, et créer un nouvel objet après le redémarrage. La bibliothèque fournit des événements sous la forme des méthodes déléguées onCodePreLoad
et onCodePostLoad
que l'application peut traiter.
Je ne sais pas comment (et si) il est possible de résoudre cette situation de manière générale, je pense. Maintenant ce cas "plus ou moins normal" ne fonctionnera que pour les variables statiques, il utilise la logique suivante:
void* oldVarPtr = ...; void* newVarPtr = ...; size_t oldVarSize = ...; size_t newVarSize = ...; memcpy(newVarPtr, oldVarPtr, std::min(oldVarSize, newVarSize));
Ce n'est pas très correct, mais c'est le meilleur que j'ai trouvé.
Par conséquent, le code se comportera de manière imprévisible si le runtime modifie l'ensemble et la disposition des champs dans les structures de données. Il en va de même pour les types polymorphes.
Tout mettre ensemble
Comment tout cela fonctionne ensemble.
- La bibliothèque parcourt les en-têtes de toutes les bibliothèques chargées dynamiquement dans le processus et, en fait, le programme lui-même, analyse et filtre les caractères.
- Ensuite, la bibliothèque essaie de trouver le fichier
compile_commands.json
dans le répertoire de l'application et dans les répertoires parents de manière récursive, et en extrait toutes les informations nécessaires sur ET. - Connaissant le chemin d'accès aux fichiers objets, la bibliothèque charge et analyse les fichiers de dépôt.
- Après cela, le répertoire le plus courant pour tous les fichiers de code source du programme est calculé et la surveillance de ce répertoire commence récursivement.
- Lorsqu'un fichier change, la bibliothèque cherche à voir s'il se trouve dans la table de hachage des dépendances et, le cas échéant, démarre plusieurs processus de compilation des fichiers modifiés et leurs dépendances en arrière-plan, à l'aide des commandes de compilation de
compile_commands.json
. - Lorsque le programme vous demande de recharger le code (dans mon application, la combinaison
Ctrl+r
est affectée), la bibliothèque attend la fin des processus de compilation et relie tous les nouveaux objets à la bibliothèque dynamique. - Cette bibliothèque est ensuite chargée dans l'espace d'adressage du processus à l'
dlopen
fonction dlopen
. - Les informations sur les symboles sont chargées à partir de cette bibliothèque, et l'intersection complète de l'ensemble des symboles de cette bibliothèque et des symboles déjà présents dans le processus est soit rechargée (s'il s'agit d'une fonction) soit transférée (s'il s'agit d'une variable statique).
Cela fonctionne très bien, surtout lorsque vous savez ce qui se cache sous le capot et à quoi s'attendre, au moins à un niveau élevé.
Personnellement, j'ai été très surpris par l'absence d'une telle solution pour Linux, cela intéresse-t-il vraiment quelqu'un?
Je serai heureux de toute critique, merci!
Lien avec la mise en œuvre