Beweisführung: Debug vs Release

Ich könnte mich in den Arsch beißen, dass ausgerechnet mir soetwas 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:

  1. Der C++ Heap, Templates und automatisch ausgeführte Konstruktor- und Destruktor-Routinen.
  2. 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 einem delete im Hauptprogramm aufgeräumt werden darf. Das gilt auch für Konstruktoren ohne new, die z.B. bei einem return 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 und delete können überladen werden und somit abhängig von include-Konstellationen andere Ergebnisse liefen, womit ebenfalls ein delete mit einem ihm fremden new 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ändung, 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 …


Wenn sich eine triviale Erkenntnis mit Dummheit in der Interpretation paart, dann gibt es in der Regel Kollateralschäden in der Anwendung.
frei zitiert nach A. Van der Bellen
... also dann paaren wir mal eine komplexe Erkenntnis mit Klugheit in der Interpretation!