Gut, um nochmal strukturiert das ganze in Einzelteile zu zerlegen. Es handelt sich um zwei völlig unterschiedliche Probleme, die aber in ihrer Wirkung völllig gleich sind (undefinierte Abstürze und ein ratloser Debugger). Aus deiner anfänglichen Beschreibung habe ich vermutet dass du womöglich gleichzeitig Opfer beider geworden bist, hab sie dann aber evtl. zu wenig stark voneinander angegrenzt:
(i) ABI-Kompatibilität. Damit der C++-Compiler dir erlaubt ein fremdes API aufzurufen, prüft er nur das öffentlich verfügbare API, im Falle einer einfachen Funktion als nur deren Signatur auf korrekte Verwendung. Wenn eine Funktion einen
std::string als Parameter haben will, so wird jeder C++-Compiler einen entsprechenden Aufruf zulassen. Wenn der Linker beim Resolven des Aufrufes nach Erfolg hat - sprich er in irgendeiner Importbibliothek oder Übersetzungseinheit ein entsprechendes Symbol findet, so führt auch er keine weiteren Checks durch.
Damit der Aufruf aber problemlos funktioniert, müssen Aufrufer und aufgerufene Funktion sich aber nicht nur darüber einig sein, dass das übergebene Objekt
std::string heißt und einen Satz Methoden à la
length(), c_str(), find_first_of(), rbegin(), ~string … besitzt. Tatsächlich muss das binäre Layout im Speicher Bit für Bit übereinstimmen. Nachdem der C++-Sprachstandard nur die öffentliche Schnittstelle seiner Container, nicht aber die Implementierung, definiert, ist folgendes legal (Beispiel, vereinfacht, entspricht nicht dem geforderten API eines
std::vector's!):
Code: Alles auswählen
template <class T>
class vector {
public:
bool empty() ..
size_type length() ..
T operator[] ..
private:
#ifdef DEBUG
SomeWhat debugging_data;
#endif
size_t length;
T* data;
};
Und das ist der Grund, wieso Debug und Release-Builds inkompatibel sind bzw. inkompatibel zueinander sein können ohne dass es dem Linker auffällt. Das gleiche gilt für verschiedene VC-versionen. Theoretisch könnte ein Linker in sein Name-Mangling (das vom Standard bewusst nicht definiert wird) aber auch entsprechende Informationen eingehen lassen um solche Fälle zu erkennen. Macht VC nur leider nicht. Die STL ist das Paradebeispiel, da insbesondere Microsoft in Debug-Builds eine Menge Debug-Checking betreibt und dieses z.B. von VC8 auf VC9 komplett umgeschmissen wurde. Ironischerweise sind die Release-ABIs verschiedener VC-STLs auch noch weitestgehend kompatibel, während Debug-Versionen durch verschiedene Präprozessor-Defines dann auch noch
zu sich selber inkompatibel sein können – Microsoft hat gute Arbeit geleistet :-)
(ii) Shared heaps. Dies ist eine Implementierungssache - der C++-Sprachstandard kennt keine `Module` - dieses Konzept wird vom Linker umgesetzt und ist in ähnlicher Form sowohl auf Posix-Systemen als auch unter Windows zu finden.
Unter Windows hat jeder Prozess einen eigenen Heap, WinAPIs wie
LocalAlloc operieren auf diesem und funktionieren – afaik – auch über Modulgrenzen. Leider oder gluecklicherweise laufen Allokationen ja meist über den C- bzw C++ way of life, d.h.
malloc, calloc, realloc, free, ::new, ::delete. Diese werden von der Standardbibliothek zur Verfügung gestellt, die ihren Heap-Manager selber implementiert (auf dem OS-Heapmanager aufsetzend) und, bei nicht-DLL Runtimes, in jedem Modul auf's neue statisch hinzugelinkt wird. Kurz: jedes Modul hat - vereinfacht - seinen eigenen, logischen Heap. Speicher muss mit dem Allokator freigegeben werden, der ihn auch angefordert hat - und vom gleichen Modul aus. Siehe
http://blogs.msdn.com/oldnewthing/archi ... 55966.aspx.
.nicht genau, was du meinst mit "D'tor wird auf unserem Heap aufgerufen und crasht"
Ich gehe im folgenden davon aus, dass (i) erfüllt ist. Der
std::string d'tor ist als Template inline in der Standardbibliothek definiert. Beim Aufruf einer DLL–Funktion, die einen string by-value zurückgibt, wird dieser unweigerlich irgendwann zerstört - vom Aufrufer, wobei der Compiler die ihm bekannte Definition des d'tors auf dem Objekt aufruft, der dann aller Wahrscheinlichkeit das ihm bekannte
delete aufrufen wird um den für den String allozierten Speicher zu zerstören. Bumm.
Dass es mit
Multithreaded-Dll „funktioniert“ ist wiederum ebenfalls ein Implementierungsdetail - oder eher ein Nebeneffekt der Tatsache dass die Heap-Funktionen aus der Laufzeit-DLL importiert werden, und diese - wenn von mehreren Modulen referenziert - nur einmal in den Adressraum geladen wird, womit alle Allokationen von einem Heap stammen. Sich darauf zu verlassen halte ich für nicht ratsam. Das kann sich jederzeit ändern, auf anderen Plattformen gibt es dann womöglich keinen solchen Trick und der ganze Code ist für den Mülleimer. Zudem erfordert die DLL-Runtime auch noch die Installation des Runtime-Redistributables auf jedem Zielsystem (irgendwie müssen die DLLs ja auf das System kommen). Rein theoretisch müssten die auf jedem System schon von einem anderen Programm installiert wurden sein, praktisch ist das irgendwie nie der Fall. Das ist dann Platz 2 auf der Top 10 der häufigsten Stolpersteine für Windows-Entwickler :-)
2. du meintest, all diese Probleme betreffen nur C++, nicht C. Hängt das Problem also direkt und ausschließlich mit den C'tors und D'tors zusammen ? Oder hängt da noch mehr drin ?
beide sind universell, aber insbesondere im Zusammenhang mit C++ gefährlich. Auf der einen Seite wird extensive Verwendung von STL und Boost als guter Stil, idiomatischer Stil gehandelt - andererseits sind nahezu alle STL/Boost-Tools anfällig für (i) und (ii). Hauptsaechlich weil diese a) generell nur das API, nicht das ABI, definieren und b) weil ein Großteil der Funktionalität
inline implementiert ist, d.h. vom Compiler in alle Uebersetzungseinheiten und somit auch alle Module dupliziert wird. Schlussendlich hast du z.B. bereits beim Austausch von C-Strukturen (`PODs`) das Problem, dass padding, alignment usw. zwischen verschiedenen Builds mit womöglich verschiedenen Compilern abweichen können.
Grundsätzlich hat es mit c'tors, d'tors nichts zu tun. Folgendes ist beispielsweise völlig in Ordnung -- (
test in der DLL implementiert):
Code: Alles auswählen
class __declspec(dllimport) test {
public:
test();
~test();
public:
void foo();
private:
void* somewhat;
};
int main(int, char*[]) {
test* t = new test();
t->foo();
delete t;
}
Allokation und Deallokation finden auf dem gleichen (`unserem`) Heap statt, das Objekt gehört uns, obwohl D'tor und C'tor in der DLL liegen. Beide können selber so viel Speicher allozieren und freigeben wie sie wollen (z.b. für
test::somewhat) – sie sind ja beide im gleichen Modul.
Mein Lösungsvorschlag oben klang vielleicht zu rabiat. Allgemein gibt es mehrere Wege:
- Im API auf jegliche „Spielereien“ verzichten - idealerweise komplett C (auch mit entsprechender Linkage). Beim Austausch von Datenstrukturen alle unterstützten Compiler dazu zwingen, diese im Speicher gleich anzulegen (siehe #pragma pack, __attribute__ ((packed))).
- Komplett auf den by-value Austausch und den direkten Zugriff auf Datenstrukturen verzichten. Siehe letzter Abschnitt.
- Für den Fall, dass es nur um ein privates Projekt geht und nicht um eine Bibliothek für Jedermann and his dog, lautet die pragmatische Lösung: einfach *immer* alle Module im Projekt mit gleichen Bedingungen kompilieren und linken.
Wir haben uns bei Assimp notgedrungen für Lösung 1 entschieden.
std::string war bei uns was (ii) angeht der einzige Problempunkt - alle
std::string-APIs wurden durch
const char*'s ersetzt (die alten APIs existieren immer noch, wrappen bloß inline die Neuen). Das wurde nötig nachdem es fast 10 Millionen Threads im SF.net Supportforum deswegen gab, alle mit mysteriösen, nicht-debugbaren Abstürzen. (i) hat uns auch Probleme bereitet, da unseres API in Bezug auf Object-Ownership teilweise etwas unsauber designt ist - Instanzen einer bestimmten Klasse werden u.U. vom Aufrufer erstellt, aber von Assimp gelöscht. Anfangs war die Empfehlung nur
Multithreaded-Dll zu nutzen (weil es da ja funktionierte …). Leider kann man als externe Lib nicht einfach den Leuten vorgeben was für eine Laufzeitbibliothek sie zu nutzen haben. Anstelle das API zu ändern hat ein überladener
operator new für besagte Klasse das Problem gelöst - er führt bereits die Allokation durch den User auf dem Assimp'schen Heap durch. Im oben verlinkten Chen-Artikel entsprich das dem Konzept eines `externen Allokators`.
. Ich benutze FMOD und hab dabei festgestellt, dass ich ohne Probleme deren release-Version auch in meiner debug-config benutzen kann
Ich kenne FMOD und sein API nicht. Ich beziehe mich im folgenden auf
diesen GD.net-Artikel.
Code: Alles auswählen
int main ()
{
// init FMOD sound system
FSOUND_Init (44100, 32, 0);
// load song
handle=FMUSIC_LoadSong ("canyon.mid");
// play song only once
// when you want to play a midi file you have to disable looping
// BEFORE playing the song else this command has no effect!
FMUSIC_SetLooping (handle, false);
// play song
FMUSIC_PlaySong (handle);
// wait until the users hits a key to end the app
while (!_kbhit())
{
}
//clean up
FMUSIC_FreeSong (handle);
FSOUND_Close();
}
Ein sauber designtes API, das sowohl den Problemen (i) und (ii) aus dem Weg geht:
- FMOD wird nicht durch by-value übergebene Datenstrukturen repräsentiert, sondern über ein opakes Handle - schlussendlich vermutlich ein Pointer in einen Speicherbereich der FMOD-Runtime. Das Handle selber wird durch ein FMUSIC_LoadSong, FMUSIC_FreeSong in's Leben gerufen und auch wieder zerstört. Auch das ist quasi ein externer Allokator.
- Um Methoden darauf aufzurufen, werden immer externe Funktionen aus der FMOD-Runtime bemüht, die bloß das Handle übergeben bekommen und dann sicher darauf zugreifen können.
Alex