Implementierung eines Hot-Reloads von C ++ - Code unter Linux

Bild


* Link zur Bibliothek am Ende des Artikels. Der Artikel selbst beschreibt die in der Bibliothek implementierten Mechanismen mit mittleren Details. Die Implementierung für macOS ist noch nicht abgeschlossen, unterscheidet sich jedoch nicht wesentlich von der Implementierung für Linux. Dies ist hauptsächlich eine Implementierung für Linux.


Als ich an einem Samstagnachmittag über den Github ging, stieß ich auf eine Bibliothek , in der die Aktualisierung von C ++ - Code für Windows im laufenden Betrieb implementiert wird. Ich selbst bin vor ein paar Jahren von Windows heruntergekommen, habe es nicht ein bisschen bereut, und jetzt erfolgt die gesamte Programmierung entweder unter Linux (zu Hause) oder unter MacOS (bei der Arbeit). Beim Googeln stellte ich fest, dass der Ansatz aus der obigen Bibliothek sehr beliebt ist und msvc dieselbe Technik für die Funktion "Bearbeiten und fortfahren" in Visual Studio verwendet. Das einzige Problem ist, dass ich unter Nicht-Fenstern keine Implementierungen gefunden habe (habe ich schlecht ausgesehen?). Auf die Frage an den Autor der Bibliothek oben, ob er einen Port für andere Plattformen erstellen wird, lautete die Antwort nein.


Ich muss sofort sagen, dass ich nur an der Option interessiert war, bei der ich den vorhandenen Projektcode nicht ändern müsste (wie zum Beispiel im Fall von RCCPP oder cr , wo sich der gesamte potenziell neu geladene Code in einer separaten dynamisch geladenen Bibliothek befinden sollte).


"Wie so?" - Ich dachte und fing an, Weihrauch anzuzünden.


Warum?


Ich mache hauptsächlich Gamedev. Die meiste Zeit meiner Arbeit verbringe ich damit, Spielelogik und Layout für jedes Bild zu schreiben. Ich benutze imgui auch für Hilfsdienstprogramme. Mein Zyklus der Arbeit mit dem Code ist, wie Sie wahrscheinlich vermutet haben, Schreiben -> Kompilieren -> Ausführen -> Wiederholen. Alles geschieht ziemlich schnell (inkrementeller Build, alle Arten von Ccache usw.). Das Problem hierbei ist, dass dieser Zyklus oft genug wiederholt werden muss. Zum Beispiel schreibe ich eine neue Spielmechanik, sei es "Jump", ein gültiger, kontrollierter Jump:


1. Schrieb einen Entwurf der Implementierung basierend auf der Dynamik, zusammengestellt, gestartet. Ich habe gesehen, dass ich versehentlich einen Impuls auf jedes Bild angewendet habe und nicht einmal.


2. Fest, zusammengebaut, gestartet, jetzt normal. Aber es wäre notwendig, den absoluten Wert des Impulses mehr zu nehmen.


3. Fest, zusammengebaut, gestartet, funktioniert. Aber irgendwie fühlt es sich falsch an. Es ist notwendig, auf der Grundlage der Stärke zu versuchen, zu tun.


4. Schrieb einen Implementierungsentwurf basierend auf Stärke, zusammengestellt, gestartet, funktioniert. Es wäre nur notwendig, die momentane Geschwindigkeit zum Zeitpunkt des Sprunges zu ändern.
...


10. Fest, zusammengebaut, gestartet, funktioniert. Aber immer noch nicht das. gravityScale müssen Sie eine Implementierung versuchen, die auf einer Änderung der gravityScale .
...


20. Großartig, es sieht super aus! Jetzt nehmen wir alle Parameter im Editor für das Spiel heraus, testen und füllen.
...


30. Der Sprung ist fertig.


Und bei jeder Iteration müssen Sie den Code sammeln und in der gestarteten Anwendung an die Stelle gelangen, an der ich springen kann. Dies dauert normalerweise mindestens 10 Sekunden. Und wenn ich nur in einen offenen Bereich springen kann, der noch erreicht werden muss? Und wenn ich in der Lage sein muss, auf Blöcke mit einer Höhe von N Einheiten zu springen? Hier muss ich bereits eine Testszene sammeln, die ebenfalls debuggt werden muss und die auch Zeit verbringen muss. Für solche Iterationen wäre ein Hot-Reload von Code ideal. Natürlich ist dies kein Allheilmittel, es ist nicht für alles geeignet. Nach einem Neustart müssen Sie manchmal einen Teil der Spielwelt neu erstellen, und dies muss berücksichtigt werden. In vielen Fällen kann dies jedoch nützlich sein und Aufmerksamkeit und viel Zeit sparen.


Anforderungen und Erklärung des Problems


  • Beim Ändern des Codes sollte die neue Version aller Funktionen die alten Versionen derselben Funktionen ersetzen
  • Dies sollte unter Linux und MacOS funktionieren
  • Dies sollte keine Änderungen am vorhandenen Anwendungscode erfordern.
  • Im Idealfall sollte dies eine Bibliothek sein, die statisch oder dynamisch mit der Anwendung verknüpft ist und keine Dienstprogramme von Drittanbietern enthält
  • Es ist wünschenswert, dass diese Bibliothek die Anwendungsleistung nicht stark beeinflusst.
  • Genug, wenn dies mit cmake + make / ninja funktioniert
  • Es reicht aus, wenn es mit Debazin-Builds funktioniert (ohne Optimierungen, ohne Zuschneiden von Zeichen usw.).

Dies ist die Mindestanforderung, die eine Implementierung erfüllen muss. Mit Blick auf die Zukunft werde ich kurz beschreiben, was zusätzlich implementiert wurde:


  • Übertragen von Werten statischer Variablen in neuen Code (siehe Abschnitt "Übertragen statischer Variablen", um herauszufinden, warum dies wichtig ist)
  • Neuladen basierend auf Abhängigkeiten (geänderter Header -> neu erstellt ein halbes Projekt alle abhängigen Dateien)
  • Code aus dynamischen Bibliotheken neu laden

Implementierung


Bis zu diesem Moment war ich völlig weit vom Themenbereich entfernt, daher musste ich Informationen von Grund auf sammeln und verarbeiten.


Auf hoher Ebene sieht der Mechanismus folgendermaßen aus:


  • Wir überwachen das Dateisystem auf Änderungen in der Quelle
  • Wenn sich die Quelle ändert, erstellt die Bibliothek sie mithilfe des Kompilierungsbefehls neu, mit dem diese Datei bereits kompiliert wurde
  • Alle gesammelten Objekte sind mit einer dynamisch geladenen Bibliothek verknüpft
  • Die Bibliothek wird in den Prozessadressraum geladen
  • Alle Funktionen aus der Bibliothek ersetzen dieselben Funktionen in der Anwendung.
  • Die Werte statischer Variablen werden von der Anwendung in die Bibliothek übertragen

Beginnen wir mit dem interessantesten - dem Mechanismus zum erneuten Laden von Funktionen.


Funktionen zum Nachladen


Hier sind 3 mehr oder weniger beliebte Möglichkeiten, um Funktionen in (oder fast) zur Laufzeit zu ersetzen:


  • Trick mit LD_PRELOAD - Ermöglicht es Ihnen, eine dynamisch geladene Bibliothek mit beispielsweise der Funktion strcpy und so zu gestalten, dass beim Starten der Anwendung meine Version von strcpy anstelle der Bibliothek verwendet wird
  • PLT- und GOT-Tabellen ändern - Ermöglicht das "Überladen" exportierter Funktionen
  • Funktions-Hooking - Ermöglicht das Umleiten des Ausführungsthreads von einer Funktion zur anderen

Die ersten beiden Optionen sind natürlich nicht geeignet, da sie nur mit exportierten Funktionen funktionieren und wir nicht alle Funktionen unserer Anwendung mit Attributen markieren möchten. Daher ist Function Hooking unsere Option!


Kurz gesagt, das Einhaken funktioniert folgendermaßen:


  • Die Funktionsadresse wird gefunden
  • Die ersten paar Bytes der Funktion werden durch einen bedingungslosen Übergang zum Körper einer anderen Funktion überschrieben
  • ...
  • Gewinn!
    In msvc gibt es 2 Flags für dieses - /hotpatch und /FUNCTIONPADMIN . Der erste am Anfang jeder Funktion schreibt 2 Bytes, die nichts tun, für das anschließende Umschreiben mit einem "kurzen Sprung". Mit der zweiten Option können Sie vor dem Körper jeder Funktion einen leeren Raum in Form von nop Anweisungen für einen "Weitsprung" zum gewünschten Ort lassen, sodass Sie in zwei Sprüngen von der alten Funktion zur neuen wechseln können. Weitere Informationen dazu, wie dies beispielsweise in Windows und MSVC implementiert ist, finden Sie hier .

Leider gibt es in clang und gcc nichts Vergleichbares (zumindest unter Linux und macOS). Eigentlich ist das kein so großes Problem, wir werden direkt über die alte Funktion schreiben. In diesem Fall besteht die Gefahr, dass wir Probleme bekommen, wenn unsere Anwendung über mehrere Threads verfügt. Wenn wir normalerweise in einer Umgebung mit mehreren Threads den Zugriff auf Daten durch einen Thread einschränken, während ein anderer Thread sie ändert, müssen wir die Fähigkeit, Code auszuführen, auf einen Thread beschränken, während ein anderer Thread diesen Code ändert. Ich habe nicht herausgefunden, wie das geht, daher wird sich die Implementierung in einer Multithread-Umgebung unvorhersehbar verhalten.


Es gibt einen subtilen Punkt. Auf einem 32-Bit-System reichen 5 Bytes aus, um an einen beliebigen Ort zu "springen". Wenn wir auf einem 64-Bit-System die Register nicht verderben möchten, benötigen wir 14 Bytes. Die Quintessenz ist, dass 14 Bytes in der Skala des Maschinencodes ziemlich viel sind, und wenn der Code eine Stub-Funktion mit einem leeren Körper hat, ist er wahrscheinlich weniger als 14 Bytes lang. Ich kenne nicht die ganze Wahrheit, aber ich habe einige Zeit hinter dem Disassembler verbracht, während ich den Code gedacht, geschrieben und debuggt habe, und festgestellt, dass alle Funktionen an einer 16-Byte-Grenze ausgerichtet sind (Debug-Build ohne Optimierungen, nicht sicher über optimierten Code). Dies bedeutet, dass zwischen dem Beginn von zwei beliebigen Funktionen mindestens 16 Bytes liegen, was ausreicht, um sie zu „stören“. Oberflächliches Googeln führte hierher , aber ich weiß nicht genau, ich hatte einfach Glück, oder heute tun dies alle Compiler. In jedem Fall deklarieren Sie im Zweifelsfall einfach einige Variablen am Anfang der Stub-Funktion, damit sie groß genug wird.


Wir haben also das erste Korn - einen Mechanismus zum Umleiten von Funktionen von der alten zur neuen Version.


Suchen Sie nach Funktionen in einem kopierten Programm


Jetzt müssen wir irgendwie die Adressen aller (nicht nur exportierten) Funktionen aus unserem Programm oder einer beliebigen dynamischen Bibliothek abrufen. Dies kann ganz einfach mit der System-API erfolgen, wenn keine Zeichen aus Ihrer Anwendung ausgeschnitten sind. Unter Linux sind dies elf.h von elf.h und link.h , unter macOS, loader.h und nlist.h .


  • Mit dl_iterate_phdr gehen wir alle geladenen Bibliotheken und sogar das Programm durch
  • Suchen Sie die Adresse, an der die Bibliothek geladen wird
  • Aus dem Abschnitt .symtab alle Informationen zu den Zeichen, nämlich Name, Typ, Index des Abschnitts, in dem er liegt, Größe und berechnen auch seine "echte" Adresse basierend auf der virtuellen Adresse und der Ladeadresse der Bibliothek

Es gibt eine Subtilität. Beim Herunterladen einer Elf-Datei lädt das System den Abschnitt .symtab (richtig, wenn er falsch ist), und der Abschnitt .dynsym passt nicht zu uns, da wir keine Zeichen mit der Sichtbarkeit STV_INTERNAL und STV_HIDDEN . Einfach ausgedrückt, wir werden solche Funktionen nicht sehen:


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

und solche Variablen:


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

Daher arbeiten wir in Absatz 3 nicht mit dem Programm, das dl_iterate_phdr zur Verfügung gestellt dl_iterate_phdr , sondern mit der Datei, die wir von der Festplatte heruntergeladen und von einem Elfen-Parser (oder auf der nackten API) analysiert haben. Wir verpassen also nichts. Unter macOS ist die Vorgehensweise ähnlich, nur die Namen der Funktionen aus der System-API unterscheiden sich.


Danach filtern wir alle Zeichen und speichern nur:


  • Funktionen, die neu geladen werden können, sind Zeichen vom Typ .text Abschnitt .text , deren Größe ungleich Null ist. Ein solcher Filter überspringt nur Funktionen, deren Code tatsächlich in diesem Programm oder dieser Bibliothek enthalten ist
  • Statische Variablen, deren Werte Sie übertragen möchten, sind Zeichen vom Typ STT_OBJECT im Abschnitt .bss

Broadcast-Einheiten


Um den Code neu zu laden, müssen wir wissen, woher die Quellcodedateien stammen und wie sie kompiliert werden.


In der ersten Implementierung habe ich diese Informationen aus dem Abschnitt .debug_info gelesen, der Debugging-Informationen im DWARF-Format enthält. Damit jede Kompilierungseinheit (ET) in DWARF eine Kompilierungszeile für diese ET erhält, müssen Sie während der Kompilierung -grecord-gcc-switches . DWARF selbst, ich habe die libdwarf-Bibliothek analysiert, die im Lieferumfang von libelf . Zusätzlich zum Kompilierungsbefehl von DWARF können Sie Informationen zu den Abhängigkeiten unserer ETs von anderen Dateien abrufen. Ich habe diese Implementierung jedoch aus mehreren Gründen abgelehnt:


  • Bibliotheken sind ziemlich schwer
  • Das Parsen einer aus ~ 500 ET kompilierten DWARF-Anwendung mit Abhängigkeitsanalyse dauerte etwas mehr als 10 Sekunden

10 Sekunden zum Starten der Anwendung sind zu viel. Nach einigem Überlegen habe ich die Logik des Parsens von DWARF in das Parsen von compile_commands.json . Diese Datei kann einfach durch Hinzufügen von set(CMAKE_EXPORT_COMPILE_COMMANDS ON) zu Ihrer CMakeLists.txt generiert werden. So erhalten wir alle Informationen, die wir brauchen.


Abhängigkeitsbehandlung


Da wir DWARF aufgegeben haben, müssen wir eine andere Option finden, wie Abhängigkeiten zwischen Dateien behandelt werden können. Ich wollte wirklich keine Dateien mit meinen Händen analysieren und nach Includes suchen, und wer weiß mehr über Abhängigkeiten als der Compiler selbst?


Es gibt eine Reihe von Optionen in clang und gcc, die fast kostenlos sogenannte Depfiles generieren. Diese Dateien verwenden die Build- und Ninja-Build-Systeme, um Abhängigkeiten zwischen Dateien aufzulösen. Depfiles haben ein sehr einfaches Format:


 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 \ ... 

Der Compiler platziert diese Dateien neben den Objektdateien für jede ET. Es bleibt uns überlassen, sie zu analysieren und in eine Hashmap einzufügen. Das gesamte Parsen von compile_commands.json + depfiles für dieselben 500 ET dauert etwas mehr als 1 Sekunde. Damit alles funktioniert, müssen wir das -MD Flag global für alle Projektdateien in der Kompilierungsoption hinzufügen.


Mit Ninja ist eine Subtilität verbunden. Dieses Build-System generiert Depfiles unabhängig vom Vorhandensein des -MD Flags für ihre Anforderungen. Nachdem sie generiert wurden, werden sie in das Binärformat übersetzt und die Quelldateien gelöscht. Daher müssen Sie beim Starten von Ninja das Flag -d keepdepfile . Aus mir unbekannten Gründen heißt die Datei im Fall von make (mit der Option -MD ) some_file.cpp.d , während sie bei ninja some_file.cpp.od heißt. Daher müssen Sie nach beiden Versionen suchen.


Statische Variablenübertragung


Angenommen, wir haben einen solchen Code (ein sehr synthetisches Beispiel):


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

Wir möchten die Funktion veryUsefulFunction ändern:


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

Beim erneuten Laden wird in der dynamischen Bibliothek mit neuem Code zusätzlich zu veryUsefulFunction die statische Variable static Singleton ins; und die Singletor::instance Methode. Infolgedessen ruft das Programm neue Versionen beider Funktionen auf. Die statischen ins in dieser Bibliothek sind jedoch noch nicht initialisiert. Daher wird beim ersten Zugriff der Konstruktor der Singleton Klasse aufgerufen. Das wollen wir natürlich nicht. Daher überträgt die Implementierung die Werte aller dieser Variablen, die sie in der zusammengestellten dynamischen Bibliothek findet, vom alten Code in diese sehr dynamische Bibliothek mit dem neuen Code zusammen mit ihren Schutzvariablen .


Es gibt einen subtilen und allgemein unlösbaren Moment.
Angenommen, wir haben eine Klasse:


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

Die Methode calledEachUpdate 60 Mal pro Sekunde aufgerufen. Wir ändern es, indem wir ein neues Feld hinzufügen:


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

Befindet sich eine Instanz dieser Klasse im dynamischen Speicher oder auf dem Stapel, stürzt die Anwendung nach dem erneuten Laden des Codes wahrscheinlich ab. Die zugewiesene Instanz enthält nur die Variable m_someVar1 . Nach einem Neustart versucht die Methode m_someVar1 jedoch, calledEachUpdate zu ändern und zu ändern, was nicht zu dieser Instanz gehört, was zu unvorhersehbaren Konsequenzen führt. In diesem Fall wird die Statusübertragungslogik an den Programmierer übertragen, der den Status des Objekts irgendwie speichern und das Objekt selbst löschen muss, bevor der Code neu geladen wird, und nach dem Neustart ein neues Objekt erstellen muss. Die Bibliothek bietet Ereignisse in Form der onCodePreLoad und onCodePostLoad , die die Anwendung verarbeiten kann.


Ich weiß nicht, wie (und ob) es möglich ist, diese Situation allgemein zu lösen, denke ich. Jetzt funktioniert dieser Fall "mehr oder weniger normal" nur für statische Variablen. Er verwendet die folgende Logik:


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

Das ist nicht sehr richtig, aber es ist das Beste, was ich mir ausgedacht habe.


Infolgedessen verhält sich der Code unvorhersehbar, wenn die Laufzeit die Menge und das Layout der Felder in den Datenstrukturen ändert. Gleiches gilt für polymorphe Typen.


Alles zusammenfügen


Wie das alles zusammenarbeitet.


  • Die Bibliothek durchläuft die Header aller Bibliotheken, die dynamisch in den Prozess geladen werden, und tatsächlich analysiert und filtert das Programm selbst Zeichen.
  • Als Nächstes versucht die Bibliothek, die Datei compile_commands.json rekursiv im Anwendungsverzeichnis und in den übergeordneten Verzeichnissen zu finden, und compile_commands.json von dort aus alle erforderlichen Informationen zu ET ab.
  • Wenn die Bibliothek den Pfad zu Objektdateien kennt, werden Depfiles geladen und analysiert.
  • Danach wird das häufigste Verzeichnis für alle Quellcodedateien des Programms berechnet und die Überwachung dieses Verzeichnisses beginnt rekursiv.
  • Wenn sich eine Datei ändert, prüft die Bibliothek, ob sie sich in der Hashmap der Abhängigkeiten befindet. Wenn dies der Fall ist, werden mehrere Kompilierungsprozesse der geänderten Dateien und ihrer Abhängigkeiten im Hintergrund mithilfe der Kompilierungsbefehle aus compile_commands.json .
  • Wenn das Programm Sie auffordert, den Code neu zu laden (in meiner Anwendung ist die Kombination Ctrl+r diesem zugewiesen), wartet die Bibliothek auf den Abschluss des Kompilierungsprozesses und verknüpft alle neuen Objekte mit der dynamischen Bibliothek.
  • Diese Bibliothek wird dann dlopen Funktion dlopen in den Prozessadressraum geladen.
  • Informationen zu Symbolen werden aus dieser Bibliothek geladen, und der gesamte Schnittpunkt der Symbolmenge aus dieser Bibliothek und der bereits im Prozess lebenden Symbole wird entweder neu geladen (wenn es sich um eine Funktion handelt) oder übertragen (wenn es sich um eine statische Variable handelt).

Dies funktioniert sehr gut, insbesondere wenn Sie wissen, was sich unter der Haube befindet und was Sie zumindest auf hohem Niveau erwartet.


Persönlich war ich sehr überrascht über das Fehlen einer solchen Lösung für Linux. Interessiert sich jemand wirklich dafür?


Ich freue mich über jede Kritik, danke!


Link zur Implementierung

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


All Articles