Beweisführung: Debug vs Release
« | 10 Jun 2019 | »Ich könnte mich in den Arsch beißen, dass ausgerechnet mir so etwas passiert, wo ich doch immer wieder lange darüber rede:
Man darf Debug und Release Bibliotheken nicht durcheinander bringen.
Tja, und jetzt habe ich viel Zeit mit einem Phantom-Problem vergeudet…
In der guten alten C-Welt war noch alles in Ordnung, weil Funktionen nur “flache” Datentypen hin und her befördern konnten. Und diese “flachen” Typen waren auch nur einmal in einem Header definiert und dadurch (zufällig) in ihrem Aufbau ein binärer Standard.
Und wenn es um Speicherverwaltung ging, musste nur die von allen geteilte
C-Laufzeit mit malloc()
und free()
herhalten.
Mit C++ kamen aber (mindestens) zwei Features auf, die den
“binären C Standard” außer Kraft setzten.
Nicht grundlos deklariert der C++ Standard das Exportieren von C++ Funktionen
als “undefiniertes” bzw. nicht standardisiertes Verhalten.
Doch haben alle modernen Compiler für sich selbst ein bestimmtes Verhalten “definiert”, womit es üblich ist, dass sowohl C++ Funktionen als auch ganze Klassendefinitionen exportiert werden können.
Die zwei erwähnten Features sind:
- Der C++ Heap, Templates und automatisch ausgeführte Konstruktor- und Destruktor-Routinen.
- Spezielle Debugging-Hilfen für die Standard Bibliothek (STL)
Punkt 1. hört sich fast wie 3 Punkte an, doch im mir wichtigen Szenario spielen diese drei nämlich zusammen: Wird ein Objekt erzeugt (konstruiert), nutzt der Compiler nämlich jene Allokator-Routinen, die im aktuellen Scope “sichtbar” sind. Und bei Bibliotheken kann das eben pro Bibliothek anders ablaufen, denn:
- Zumindest unter Windows können DLLs eigene Heaps nutzen, womit ein
new
in einer Bibliothek NICHT mit einemdelete
im Hauptprogramm aufgeräumt werden darf. Das gilt auch für Konstruktoren ohnenew
, die z.B. bei einemreturn
mit dem Bibliotheks-Scope Bytes in den Callstack einer Funktion schreiben, die dem Hauptprogramm angehört. Der dortige Destruktor kann diese Daten unter Umständen dann nicht korrekt freigeben. - Die Operatoren
new
unddelete
können überladen werden und somit abhängig voninclude
-Konstellationen andere Ergebnisse liefen, womit ebenfalls eindelete
mit einem ihm fremdennew
in Konflikt gerät. - Templates machen die Sache noch komplexer, wenn sie als Header-Only Includes ebenso in jeder Übersetzungseinheit “anders” interpretiert werden können. Es existiert nicht “eine” Implementierung die immer identisch ist, sondern jede Template-Anwendung kann separate unterschiedliche Auswirkungen haben.
Doch am “störendsten” manifestiert sich die Eigenschaft der STL Implementierung, dass Strukturen je nach Kompiliereinstellung andere Objekt-Member oder auch Methoden im Angebot haben.
Eine Release-Variante eines Objektes beinhaltet meist nur jene Member, die mindestens erforderlich sind, aber eben auch nicht mehr.
Im Debug-Modus vergrößern sich jedoch viele Objekte und stellen weitere
private Member zur Verfügung um Cookies oder andere States zu speichern.
Benötigt wird das z.B. zum Iterator
Debugging.
Wir wissen ja, dass lesende Iteratoren automatisch ungültig werden,
wenn während ihrer Nutzung das Elternobjekt verändert wird.
Das ergäbe normalerweise unkontrollierte Abstürze, doch die STL
speichert daher gerne in den Eltern- und Iterator-Objekten Cookies,
die bei jedem Methodenaufruf gegengeprüft werden.
Kam es also zu einer ungewollten Veränderung, wird “kontrolliert” ein
assert
oder abort()
ausgelöst oder eine
Exception abgefeuert.
Tja, und daraus ergibt sich eben, dass in einem Gebilde aus einer Executable und mehreren Bibliotheken bei allen die gleichen Compiler-Einstellungen gelten müssen.
Ist das nicht der Fall, kann z.B. ein Release-Hauptprogramm keine
std::string
Objekte einer Debug-DLL (oder -SO) entgegennehmen, weil:
- Der Zugriff auf Methoden und die verknüpften Daten bei falschen Speicheradressen landet.
- Methoden- und Memberpointer nicht mehr auf die richtigen Adressen zeigen,
wenn
#include
Typen je nach Einstellung mit anderen Features ausgestattet sind. - Weil Allokationen, vor allem die automatischen durch C++, die
“falschen”
delete
Codes ausführen.
Man kann in Visual Studio sehr leicht ein DLL und ein EXE Projekt
erstellen, wo die DLL ein Funktion mit einem std::string
return Wert
bereitstellt und ein Konsolenprogramm, welches die Funktion aufruft.
Erstellt man dann ein Debug und ein Release Build von beiden und kopiert ganz fies die Debug DLL ins Release-Verzeichnis und umgekehrt, fängt es schon an zu krachen.
Natürlich kann auch mal alles gut gehen, beim GCC dürfte das auch öfter so sein, doch das heißt nur, dass die Korruption nicht ausreichend war um gleich aufzufallen oder dass die Korruption optisch nicht sichtbar war.
In jedem Fall kommt so eine defekt Konstellation zusammen, die dann schwer zu debuggen ist.
Daher Notiz an mich selbst (und alle anderen, denen das auch schon mal passiert ist):
Immer aufpassen, dass Debug und Release nicht gekreuzt wird.
Das Ergebnis kann schrecklich aussehen …