Reduzieren Sie die Größe Ihrer Exe-Dateien

Veröffentlicht: 15. Dez 2001 | Aktualisiert: 18. Jun 2004

Von Matt Pietrek

Durch den geschickten Einsatz von Compiler- und Linkeranweisungen sowie einer speziellen Mini-Laufzeitbibliothek kann die Größe von EXE- und DLL-Dateien ohne großen Aufwand reduziert werden.

Auf dieser Seite

Größe von EXE und DLL
Wie steht es mit der DLL-Form der C++-Laufzeitbibliothek?
Graben wir doch etwas tiefer...
LIBCTINY: eine minimalistische Laufzeitbibliothek
Der verborgene Teil der Konstruktoren
Die minimalistischen Starterfunktionen der LIBCTINY
Die LIBCTINY.LIB in der Praxis
Kleiner Nachtrag zum Metadaten-Artikel

Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:

Bild01

Bereits Anfang 1997 bin ich in einem Artikel auf eine Leseranfrage eingegangen, die sich auf die Größe von ausführbaren Dateien bezog. ("Unter der Haube von Windows", System Journal 01/1997, S. 129) Damals blähte sich ein einfaches "Hallo Welt"-Programm durch das Kompilieren zu einer ausführbaren Datei von 32 KByte auf. Zwei Compilerversionen später ergeben sich nur leicht verbesserte Werte. Kompiliert man dasselbe Programm mit Visual C++ 6.0, ist die ausführbare Datei nun 28 KByte groß.

Damals stellte ich Ihnen eine Ersatzbibliothek vor, mit der sich sehr kleine ausführbare Dateien erzeugen ließen. Natürlich gab es für diese Bibliothek einige Beschränkungen, so dass sie sich nicht für alle Anwendungsfälle eignete, aber eine große Zahl meiner eigenen Programme lief damit tadellos. Inzwischen habe ich aber lange genug mit diesen Beschränkungen gelebt und mich dazu entschlossen, einige davon zu beseitigen. Durch die geplanten Änderungen ergibt sich auch die Gelegenheit, einen kaum bekannten Linker-Schalter zu beschreiben, mit dem sich die Größe der ausführbaren Dateien weiter verringern lässt.

Größe von EXE und DLL

Bevor ich Ihnen aber den Code für meine Ersatzbibliothek vorstelle, möchte ich kurz wiederholen, warum einfache EXEs und DLLs größer werden, als man vielleicht erwartet. Betrachten wir das unvermeidliche "Hallo Welt"-Programm:

#include <stdio.h> 
void main() 
{ 
    printf("Hello World!\n"); 
}

Kompilieren Sie dieses Programm mit eingeschalteter Größenoptimierung und lassen Sie den Linker eine MAP-Datei generieren. Mit der Kommandozeilenversion des Visual C++-Kompilers sieht der entsprechende Befehl so aus:

CL /O1 Hello.CPP /link /MAP

Lassen Sie uns zuerst die MAP-Datei untersuchen. Listing L1 zeigt eine gekürzte Fassung. Aus den Adressen von main (0001:00000000) und printf (0001:0000000C) lässt sich ableiten, dass der Code der Funktion main nur 0xC Bytes lang ist. Die letzte Zeile der Datei zeigt die Funktion __chkstk an der Adresse 0001:00003B10. Die ausführbare Datei enthält also mindestens 0x3B10 Bytes an Code. Das sind über 14 KByte. Und das alles nur, um das "Hello World" auf den Bildschirm zu schicken.

L1 Auszug aus der MAP-Datei des "Hello World"-Programms

 Address         Publics by Value        Rva+Base     Lib:Object 
 0001:00000000   _main                   00401000 f   hello.obj 
 0001:0000000c   _printf                 0040100c f   LIBC:printf.obj 
 0001:0000003d   _mainCRTStartup         0040103d f   LIBC:crt0.obj 
 0001:0000011c   __amsg_exit             0040111c f   LIBC:crt0.obj 
 0001:00000165   __stbuf                 00401165 f   LIBC:_sftbuf.obj 
 0001:000001f2   __ftbuf                 004011f2 f   LIBC:_sftbuf.obj 
 0001:0000022f   __output                0040122f f   LIBC:output.obj 
 0001:00000a39   ___initstdio            00401a39 f   LIBC:_file.obj 
 0001:00000ade   ___endstdio             00401ade f   LIBC:_file.obj 
 0001:00000af2   __cinit                 00401af2 f   LIBC:crt0dat.obj 
 0001:00000b1f   _exit                   00401b1f f   LIBC:crt0dat.obj 
 0001:00000b30   __exit                  00401b30 f   LIBC:crt0dat.obj 
 0001:00000bf4   __XcptFilter            00401bf4 f   LIBC:winxfltr.obj 
 0001:00000d78   __setenvp               00401d78 f   LIBC:stdenvp.obj 
 0001:00000e31   __setargv               00401e31 f   LIBC:stdargv.obj 
 0001:0000107e   ___crtGetEnvironmentStringsA  
                                         0040207e f   LIBC:a_env.obj 
 0001:000011b0   __ioinit                004021b0 f   LIBC:ioinit.obj 
 0001:0000135b   __heap_init             0040235b f   LIBC:heapinit.obj 
 0001:00001398   __global_unwind2        00402398 f   LIBC:exsup.obj 
 0001:000013da   __local_unwind2         004023da f   LIBC:exsup.obj 
 0001:00001432   __NLG_Return2           00402432 f   LIBC:exsup.obj 
 0001:00001442   __abnormal_termination  00402442 f   LIBC:exsup.obj 
 0001:00001465   __NLG_Notify1           00402465 f   LIBC:exsup.obj 
 0001:0000146e   __NLG_Notify            0040246e f   LIBC:exsup.obj 
 0001:00001481   __NLG_Dispatch          00402481 f   LIBC:exsup.obj 
 0001:00001490   __except_handler3       00402490 f   LIBC:exsup3.obj 
 0001:0000154d   __seh_longjmp_unwind@4  0040254d f   LIBC:exsup3.obj 
 0001:00001568   __FF_MSGBANNER          00402568 f   LIBC:crt0msg.obj 
 0001:000015a1   __NMSG_WRITE            004025a1 f   LIBC:crt0msg.obj 
 0001:000016f4   _malloc                 004026f4 f   LIBC:malloc.obj 
 0001:00001706   __nh_malloc             00402706 f   LIBC:malloc.obj 
 0001:00001732   __heap_alloc            00402732 f   LIBC:malloc.obj 
 0001:00001768   __isatty                00402768 f   LIBC:isatty.obj 
 0001:0000178e   _fflush                 0040278e f   LIBC:fflush.obj 
 0001:000017c9   __flush                 004027c9 f   LIBC:fflush.obj 
 0001:00001825   __flushall              00402825 f   LIBC:fflush.obj 
 0001:000018a0   _strlen                 004028a0 f   LIBC:strlen.obj 
 0001:0000191b   _wctomb                 0040291b f   LIBC:wctomb.obj 
 0001:00001990   __aulldiv               00402990 f   LIBC:ulldiv.obj 
 0001:00001a00   __aullrem               00402a00 f   LIBC:ullrem.obj 
 0001:00001a75   __flsbuf                00402a75 f   LIBC:_flsbuf.obj 
 0001:00001b8a   _calloc                 00402b8a f   LIBC:calloc.obj 
 0001:00001c07   __fcloseall             00402c07 f   LIBC:closeall.obj 
 0001:00001c5f   _free                   00402c5f f   LIBC:free.obj 
 0001:00001c90   _strcpy                 00402c90 f   LIBC:strcat.obj 
 0001:00001ca0   _strcat                 00402ca0 f   LIBC:strcat.obj 
 0001:00001d80   __setmbcp               00402d80 f   LIBC:mbctype.obj 
 0001:00002144   ___initmbctable         00403144 f   LIBC:mbctype.obj 
 0001:00002160   _memcpy                 00403160 f   LIBC:memcpy.obj 
 0001:00002495   ___sbh_heap_init        00403495 f   LIBC:sbheap.obj 
 0001:000024d3   ___sbh_find_block       004034d3 f   LIBC:sbheap.obj 
 0001:000024fe   ___sbh_free_block       004034fe f   LIBC:sbheap.obj 
 0001:00002829   ___sbh_alloc_block      00403829 f   LIBC:sbheap.obj 
 0001:00002b32   ___sbh_alloc_new_region 00403b32 f   LIBC:sbheap.obj 
 0001:00002be3   ___sbh_alloc_new_group  00403be3 f   LIBC:sbheap.obj 
 0001:00002cde   ___crtMessageBoxA       00403cde f   LIBC:crtmbox.obj 
 0001:00002d70   _strncpy                00403d70 f   LIBC:strncpy.obj 
 0001:00002e6e   __callnewh              00403e6e f   LIBC:handler.obj 
 0001:00002e89   __commit                00403e89 f   LIBC:commit.obj 
 0001:00002ee0   __write                 00403ee0 f   LIBC:write.obj 
 0001:0000308d   __fptrap                0040408d f   LIBC:crt0fp.obj 
 0001:00003096   __lseek                 00404096 f   LIBC:lseek.obj 
 0001:00003130   __getbuf                00404130 f   LIBC:_getbuf.obj 
 0001:00003180   _memset                 00404180 f   LIBC:memset.obj 
 0001:000031d8   _fclose                 004041d8 f   LIBC:fclose.obj 
 0001:0000322e   ___crtLCMapStringA      0040422e f   LIBC:a_map.obj 
 0001:0000347d   ___crtGetStringTypeA    0040447d f   LIBC:a_str.obj 
 0001:000035d0   _memmove                004045d0 f   LIBC:memmove.obj 
 0001:00003905   __free_osfhnd           00404905 f   LIBC:osfinfo.obj 
 0001:0000397f   __get_osfhandle         0040497f f   LIBC:osfinfo.obj 
 0001:000039bc   __dosmaperr             004049bc f   LIBC:dosmap.obj 
 0001:00003a23   __close                 00404a23 f   LIBC:close.obj 
 0001:00003ad6   __freebuf               00404ad6 f   LIBC:_freebuf.obj 
 0001:00003b10   __alloca_probe          00404b10 f   LIBC:chkstk.obj 
 0001:00003b10   __chkstk                00404b10 f   LIBC:chkstk.obj

Schauen wir uns nun einige andere Zeilen aus der .MAP-Datei an. Manche sind unverzichtbar, zum Beispiel die Funktion __initstdio. Immerhin schreibt printf die Daten in eine Datei. Also ist im gewissen Umfang die Unterstützung des Stdio-Mechanismus durch Hilfsfunktionen aus der Laufzeitbibliothek erforderlich. Außerdem kann man zum Beispiel davon ausgehen, dass der printf-Code strlen aufrufen wird. Daher verwundert es auch nicht, wenn sich diese Funktion in der .MAP-Datei findet.

Aber schauen Sie sich einmal die anderen Funktionen an, zum Beispiel __sbh_heap_init. Das ist die Initialisierungsfunktion für eine spezielle Speicherhalde, von der sich die Laufzeitbibliothek kleine Speicherblöcke besorgt. Das Win32-Betriebssystem verwaltet seine eigene Speicherhalde mit den Funktionen aus der HeapAlloc-Familie. Von potentiellen Geschwindigkeitsvorteilen einmal abgesehen könnte die Visual C++-Bibliothek die entsprechenden Win32-Funktionen zur Speicherverwaltung benutzen. Sie tut es aber nicht. Folglich wird mehr Code als erforderlich in die ausführbare Datei eingebunden.

Nun wird es die meisten Leute wohl nicht weiter stören, wenn die Laufzeitbibliothek ihre eigene Speicherverwaltung implementiert, aber es gibt auch andere Beispiele. Werfen Sie bitte einen Blick auf die Funktion __crtMessageBoxA gegen Ende der MAP-Datei. Diese Funktion ermöglicht es der Laufzeitbibliothek, die Windows-Funktion MessageBox aufzurufen, obwohl die ausführbare Datei gar nicht mit der USER32.DLL gelinkt wurde. Bei einem einfachen "Hallo Welt"-Programm lässt sich aber kaum nachvollziehen, warum MessageBox eingebunden wird.

Betrachten wir ein anderes Beispiel, nämlich die Funktion __crtLCMapStringA, die Zeichenketten unter Berücksichtigung der örtlichen Gepflogenheiten umformt (Stichwort: locale). Nun erwartet man es zwar in gewisser Weise von Microsoft, Gebietsinformationen zu berücksichtigen, aber in vielen Programmen werden solche Informationen gar nicht gebraucht. Warum sollen diese Programme also für die anderen mitbezahlen?

Ich könnte noch weitere Beispiele überflüssigen Codes aufzählen, aber der entscheidende Punkt dürfte wohl deutlich geworden sein. Ein typisches kleines Programm schleppt eine ganze Menge Code-Nuggets mit sich, die eigentlich niemand schürfen will. Für sich genommen sind die einzelnen Bespiele wohl vernachlässigbar, aber nach der Faustregel "Kleinvieh macht auch Mist" kommen insgesamt bemerkenswerte Codemengen zusammen.

Wie steht es mit der DLL-Form der C++-Laufzeitbibliothek?

Aufmerksame Leser würden vermutlich sagen: "Hey, Matt! Warum nimmst du nicht einfach die DLL-Version der Laufzeitbibliothek?" Nun, in der Vergangenheit konnte ich mich damit herausreden, dass es keine einheitliche Regelung für die Namen der diversen C++-Laufzeitbibliotheken gab, die unter Windows 95, 98, NT 3.51, NT 4.0 und so weiter zu finden sind. Zum Glück liegen jene Tage aber bereits hinter uns und in den meisten Fällen kann man wohl darauf bauen, dass die MSVCRT.DLL auf den Zielmaschinen verfügbar ist.

Gesagt, getan. Nach dieser Umstellung und der Neukompilierung von Hello.CPP ist die resultierende ausführbare Datei nun auf 16 KByte zusammengeschrumpft. Nicht schlecht. Aber auch nicht sonderlich gut. Außerdem hat man den ganzen überflüssigen Code eigentlich nur an eine andere Stelle verlegt (in diesem Fall also in die MSVCRT.DLL). Zudem muss beim Programmstart eine zusätzliche DLL geladen und initialisiert werden. Diese Initialisierung erstreckt sich natürlich auch wieder über unbenutzte Dinge, in diesem Fall zum Beispiel auch auf die Bereichsunterstützung (locale). Sofern die MSVCRT.DLL genau das ist, was Sie eigentlich suchen, dann benutzen Sie einfach diese DLL. Dafür ist sie schließlich gedacht. Ich bin allerdings davon überzeugt, dass eine kleinere statisch eingebundene Laufzeitbibliothek trotzdem noch ihre Vorzüge hat.

Mag sein, dass ich hier gegen Windmühlen anrenne, aber die E-Mail-Konversation mit Lesern zeigt mir, dass ich mit meiner Überzeugung nicht alleine bin. Es gibt tatsächlich Leute, die einfach nur möglichst schlanken Code generieren möchten. In den geradezu paradiesischen Zeiten der wiederbeschreibbaren CDs, DVDs, riesiger Festplatten und schneller Internet-Verbindungen fällt es leicht, keinen Gedanken mehr an die Codegröße zu verschwenden. Allerdings schafft die schnellste Internet-Verbindung, die ich zu Hause erhalte, gerade einmal 24 Kbps. Ich warte nicht gerne auf überladene Webseiten, besonders dann, wenn die Steuerelemente, die ich für die Seite herunterladen muss, nicht schön, aber schön groß sind.

Ich achte nach Möglichkeit darauf, dass mein Code einen möglichst kleinen Fußabdruck hinterlässt. Er soll keine DLLs laden, die gar nicht gebraucht werden. Und wenn ich nicht auf eine DLL verzichten kann, versuche ich immer, sie erst bei Bedarf zu laden. Dann tritt auch die Zeitverzögerung durch den Ladevorgang nur auf, wenn die DLL tatsächlich benutzt wird. Dieses DLL-Laden bei Bedarf (Delay-Load) ist ein Thema, von dem bereits in früheren Kolumnen die Rede war. Ich kann Ihnen nur dringend empfehlen, sich damit zu beschäftigen. Für den Anfang möchte ich hier auf meinen Artikel "So funktioniert das verzögerte Laden von DLLs".

Graben wir doch etwas tiefer...

Nachdem ich nun ausgiebig über den überflüssigen Bibliothekscode geschimpft habe, möchte ich mich der ausführbaren Datei zuwenden. Wenn Sie meine Hello.EXE mit DUMPBIN /HEADERS untersuchen, werden Sie im ausgegebenen Text folgende beiden Zeilen finden:

1000 section alignment 
1000 file alignment

Die zweite Zeile ist besonders interessant. Sie besagt nämlich, dass jeder Code- und Datenabschnitt in der ausführbaren Datei an einer 0x1000-Byte-Grenze ausgerichtet wird. Das entspricht einer 4-KByte-Seitengrenze. Da die Abschnitte in der ausführbaren Datei aufeinander folgen, kann man sich leicht vorstellen, wieviel Platz damit unter Umständen zwischen dem Ende des einen und dem Anfang des nächsten Abschnitts verschwendet wird.

Hätte ich das Programm mit einem Linker zusammengebaut, der aus der Zeit vor Visual C++ 6.0 stammt, würden diese Angaben anders lauten:

1000 section alignment 
200 file alignment

Der entscheidende Unterschied besteht darin, dass die Ausrichtung der einzelnen Abschnitte an einer 0x200-Byte-Grenze erfolgt (entsprechend 512 Bytes). Dadurch wird wesentlich weniger Platz verschwendet. Im Visual C++ 6.0 wurde die Voreinstellung des Linkers so geändert, dass die Ausrichtung der Abschnitte in der Datei der Ausrichtung im Speicher entspricht. Dadurch erhält man unter Windows 9x zwar einen gewissen Geschwindigkeitsvorteil beim Laden, aber eben auch größere ausführbare Dateien.

Zum Glück erlaubt der Linker von Visual C++ aber den Rückfall in die Vergangenheit. Der magische Schalter heißt /OPT:NOWIN98. Baut man Hello.CPP wie anfangs beschrieben zusammen, diesmal allerdings mit dem Linkerschalter, der kleinere ausführbare Dateien ergibt, so schrumpft die Datei auf 21 KByte zusammen, also ungefähr 7 KByte weniger. Linkt man das Programm für die MSVCRT.DLL und benutzt wieder den Schalter /OPT:NOWIN98, so schrumpft das Programm tatsächlich auf 2560 Bytes zusammen. Erstaunlich, nicht wahr?

L2 Die Datei printf.cpp von LIBCTINY

//========================================== 
// LIBCTINY - Matt Pietrek 2001 
// System Journal 
//========================================== 
#include <windows.h> 
#include <stdio.h> 
#include <stdarg.h> 
// Veranlasse den Linker, die USER32.LIB einzubinden 
#pragma comment(linker, "/defaultlib:user32.lib") 
extern "C" int __cdecl printf(const char * format, ...) 
{ 
    char szBuff[1024]; 
    int retValue; 
    DWORD cbWritten; 
    va_list argptr; 
    va_start( argptr, format ); 
    retValue = wvsprintf( szBuff, format, argptr ); 
    va_end( argptr ); 
    WriteFile(  GetStdHandle(STD_OUTPUT_HANDLE), szBuff, retValue, 
                &cbWritten, 0 ); 
    return retValue; 
}

LIBCTINY: eine minimalistische Laufzeitbibliothek

Nachdem nun deutlich geworden sein sollte, warum einfache EXEs und DLLs so unglaublich groß werden, ist es an der Zeit, meine neue und verbesserte Ersatzbibliothek vorzustellen. In dem bereits erwähnten Artikel von 1997 hatte ich eine kleine statische .LIB-Datei konzipiert, als Ergänzung oder Ersatz der Microsoft-Bibliotheken LIBC.LIB und LIBCMT.LIB. Ich nannte diese Ersatzbibliothek LIBCTINY.LIB, da es sich eigentlich um eine sehr abgespeckte Version von den Quellen der Microsoft-Laufzeitbibliothek handelte.

Die LIBCTINY.LIB ist für einfache Anwendungen gedacht, die keine umfangreiche Laufzeitbibliothek brauchen. Daher eignet sie sich zum Beispiel nicht für MFC-Anwendungen oder andere komplexe Szenarien, in denen die C++-Laufzeitbibliothek ausgereizt wird. Das ideale Programm für die LIBCTINY ist ein kleines Programm oder eine DLL, die einige Win32-Funktionen aufruft und vielleicht noch die eine oder andere Information auf dem Bildschirm anzeigt.

Es gibt zwei Grundregeln, die bei der Entwicklung der LIBCTINY.LIB eine entscheidende Rolle spielten. Die erste ist der Ersatz der üblichen Initialisierungsroutinen von Visual C++ durch wesentlich einfacheren Code. Dieser einfachere Code verwendet keine Bibliotheksfunktionen aus der eher esoterischen Ecke, wie zum Beispiel __crtLCMapStringA. Deswegen wird auch wesentlich weniger überflüssiger Binärcode in die ausführbare Datei eingeschleppt. Wie Sie noch feststellen werden, führt die LIBCTINY nur das unverzichtbare Minimum an Arbeiten aus, bevor der Aufruf von WinMain oder DllMain erfolgt.

Die zweite Grundregel für die Entwicklung von LIBCTINY.LIB besteht darin, relativ große Funktionen wie malloc oder printf durch Code zu ersetzen, der sowieso schon in den Win32-DLLs verfügbar ist. Vom minimalistischen Initialisierungscode einmal abgesehen implementieren die meisten anderen LIBCTINY-Quelltextdateien einfach nur die üblichen C++-Bibliotheksfunktionen wie malloc, free, new, delete, printf, strupr und so weiter. Die Implementierung von printf in der Datei printf.cpp (Listing L2) wird Ihnen einen Eindruck davon verschaffen, wovon ich hier eigentlich rede.

In der alten LIBCTINY.LIB-Version gab es zwei Beschränkungen, die mich zunehmend ärgerten. Erstens ließ sich die Originalversion nicht in DLLs einsetzen. Man konnte mit ihr zwar Konsolenprogramme und kleine GUI-Programme erstellen, wer aber auf der Suche nach einer kleinen DLL war, der hatte einfach Pech gehabt.

Zweitens konnte die Original-LIBCTINY nichts mit statischen C++-Konstruktoren und Destruktoren anfangen. Damit meine ich Konstruktoren und Destruktoren, die mit globaler Gültigkeit definiert werden. In der neuen Version gibt es nun den Basiscode für diese Erweiterung. Bei der Gelegenheit habe ich übrigens eine ganze Menge darüber gelernt, welches komplexe Spiel Laufzeitbibliothek und Compiler spielen, damit statische Konstruktoren und Destruktoren funktionieren.

Der verborgene Teil der Konstruktoren

Wenn der Compiler eine Quelltextdatei verarbeitet, in der es statische Konstruktoren gibt, generiert er zwei zusätzliche Code-Konstruktionen. Die erste ist ein kleiner Codeblock mit einem Namen wie $E2, der den Konstruktor aufruft. Die zweite Konstruktion ist ein Zeiger auf diesen kleinen Codeblock. Dieser Zeiger wird in einem Abschnitt der .OBJ abgelegt, der einen ganz speziellen Namen trägt, nämlich .CRT$XCU.

Wozu dieser lustige Abschnittsname? Nun, die Erklärung ist ein wenig kompliziert. Lassen Sie mich Ihnen ein weiteres Datenstückchen vorstellen, das den Sachverhalt erhellen mag. Wenn Sie sich die Quellen der Laufzeitbibliothek von Visual C++ anschauen (zum Beispiel die CINITEXE.C), finden Sie darin folgende Konstruktion:

#pragma data_seg(".CRT$XCA") 
_PVFV __xc_a[] = { NULL }; 
#pragma data_seg(".CRT$XCZ") 
_PVFV __xc_z[] = { NULL };

Die obigen Codezeilen legen zwei Datensegmente an, nämlich .CRT$XCA und .CRT$XCZ. In beiden Segmenten entsteht zudem jeweils eine Variable (__xc_a und __xc_z). Die Segmentnamen ähneln dem Namen des Segments .CRT$XCU, in dem der Compiler den Zeiger auf den Konstruktorcode ablegt.

An dieser Stelle hilft uns nur ein wenig Theorie weiter. Wenn die Segmente bearbeitet und zur erwünschten PE-Datei (portable executable) zusammengestellt werden, fasst der Linker alle Daten aus den Segmenten mit gleichen Namen zusammen. Wenn es in der A.OBJ also einen Abschnitt namens .data gibt und in der B.OBJ ebenfalls ein Abschnitt .data zu finden ist, fasst der Linker die beiden Datenabschnitte aus A.OBJ und B.OBJ für die PE-Datei zu einem einzigen .data-Abschnitt zusammen.

Sobald das Dollarzeichen im Segmentnamen auftaucht, liegen die Verhältnisse etwas anders. Wenn der Linker nämlich auf einen Segmentnamen stößt, in dem es ein Dollarzeichen gibt, so betrachtet der Linker nur den Teil des Namens als gültigen Segmentnamen, der vor dem Dollarzeichen steht. Daher werden die Segmente .CRT$XCA, .CRT$XCU und .CRT$XCZ in der ausführbaren Datei alle zu einem Segment zusammengefasst, das den Namen .CRT erhält.

Wozu sind dann die Namensteile hinter den Dollarzeichen gut? Wenn der Linker diese Abschnitte zusammenfasst, schreibt er sie in der Reihenfolge in die lauffähige Datei, die dem Namesteil nach dem Dollarzeichen entspricht. Diese Abschnitte werden alphabetisch sortiert. Zuerst sind also alle Daten aus dem Abschnitt .CRT$XCA dran, gefolgt von den Daten aus .XRT$XCU und schließlich von .CRT$XCZ. Dieser Umstand ist für das Verständnis der Funktionsweise wichtig.

Hinter dieser Regelung verbirgt sich eigentlich nur der Umstand, dass die Laufzeitbibliothek einfach keine Vorstellung davon hat, wie viele statische Konstruktoren für eine gegebene EXE oder DLL aufgerufen werden müssen. Sie weiß aber, dass im Segment .CRT$XCU nur Zeiger auf Konstruktor-Codeblöcke liegen. Wenn der Linker also sämtliche .CRT$XCU-Abschnitte zusammenfasst, stellt er damit im Endeffekt ein Array mit Funktionszeigern zusammen. Durch die Definition der Segmente .CRT$XCA und .CRT$XCZ mit den Symbolen __xc_a und __xc_z kann die Laufzeitbibliothek Anfang und Ende dieses Arrays zuverlässig erkennen.

L3 initterm

//========================================== 
// LIBCTINY - Matt Pietrek 2001 
// System Journal 
//========================================== 
#include <windows.h> 
#include <malloc.h> 
#include "initterm.h" 
#pragma data_seg(".CRT$XCA") 
_PVFV __xc_a[] = { NULL }; 
#pragma data_seg(".CRT$XCZ") 
_PVFV __xc_z[] = { NULL }; 
#pragma data_seg()  /* zurücksetzen */ 
#pragma comment(linker, "/merge:.CRT=.data") 
typedef void (__cdecl *_PVFV)(void); 
void __cdecl _initterm ( 
        _PVFV * pfbegin, 
        _PVFV * pfend 
        ) 
{ 
    /* 
     * Gehe von unten nach oben durch die Tabelle mit den Funktions- 
     * zeigern, bis das Ende erreicht wird. Überspringe nicht den  
     * ersten Eintrag. Der Anfangswert von pfbegin zeigt auf den ersten 
     * gültigen Eintrag. Versuche nicht, über pfend eine Funktion auf- 
     * zurufen. Nur die Einträge, die vor pfend liegen, sind gültig. 
     */ 
    while ( pfbegin < pfend ) 
    { 
        // wenn der aktuelle Tabelleneintrag ungleich NULL ist, 
        // rufe die Funktion auf, die dahinter steht 
        if ( *pfbegin != NULL ) 
            (**pfbegin)(); 
        ++pfbegin; 
    } 
} 
static _PVFV * pf_atexitlist = 0; 
static unsigned max_atexitlist_entries = 0; 
static unsigned cur_atexitlist_entries = 0; 
void __cdecl _atexit_init(void) 
{ 
    max_atexitlist_entries = 32; 
    pf_atexitlist = (_PVFV *)calloc( max_atexitlist_entries, 
                                     sizeof(_PVFV*) ); 
} 
int __cdecl atexit (_PVFV func ) 
{ 
    if ( cur_atexitlist_entries < max_atexitlist_entries ) 
    { 
        pf_atexitlist[cur_atexitlist_entries++] = func;  
        return 0; 
    } 
    return -1; 
} 
void __cdecl _DoExit( void ) 
{ 
    if ( cur_atexitlist_entries ) 
    { 
        _initterm(  pf_atexitlist, 
                    // Bestimmte das Array-Ende mit etwas Zeiger-Mathe 
                    pf_atexitlist + cur_atexitlist_entries ); 
    } 
}

Wie man sich nun leicht denken kann, reduziert sich der Aufruf der statischen Konstruktoren in einem Modul auf einen Gang durch das Array mit den Funktionszeigern, wobei die dazugehörigen Funktionen der Reihe nach aufgerufen werden (Listing L3). Diese Funktion ist mit der Version aus den Quellen der Laufzeitbibliothek von Visual C++ identisch.

Nach der Klärung dieses Sachverhalts war es nicht weiter schwer, die statischen Konstruktoren in der LIBCTINY zu berücksichtigen. Im Wesentlichen geht es darum, die erforderlichen Datensegmente richtig zu definieren (insbesondere .CRT$XCA und .CRT$XCZ) und von der richtigen Stelle des Initialisierungscodes aus _initterm aufzurufen. Die statischen Destruktoren zur Mitarbeit zu überreden war schon wesentlich schwieriger.

Im Gegensatz zum Funktionszeigerarray, das Compiler und Linker mit vereinten Kräften für die statischen Konstruktoren aufstellen, wird die Liste der aufzurufenden statischen Destruktoren erst zur Laufzeit zusammengestellt. Zur Erstellung dieser Liste generiert der Compiler Aufrufe der atexit-Funktion, die zur Laufzeitbibliothek von Visual C++ gehört. Die atexit-Funktion erwartet einen Funktionszeiger als Argument, den sie in eine FILO-Liste einträgt (first in, last out). Sobald die EXE oder DLL aus dem Speicher ausgeladen werden soll, durchläuft die Laufzeitbibliothek diese Liste und ruft jede der eingetragenen Funktionen auf.

Die Implementierung der atexit-Funktionalität in der LIBCTINY ist wesentlich einfacher als das, was die Laufzeitbibliothek von Visual C++ da treibt. Die LIBCTINY kommt mit drei kurzen Funktionen und einer Handvoll statischen Variablen aus, die ebenfalls in der initterm.cpp zu finden sind. Die Funktion _atexit_init legt einfach nur ein Array für 32 Funktionszeiger an und speichert den Zeiger auf dieses Array in der statischen Variablen pf_atexitlist.

L4 DLLCRT0.CPP

//========================================== 
// LIBCTINY - Matt Pietrek 2001 
// System Journal 
// Datei: DLLCRT0.CPP 
//========================================== 
#include <windows.h> 
#include "initterm.h" 
// Veranlasse den Linker, die KERNEL32.LIB einzubinden 
#pragma comment(linker, "/defaultlib:kernel32.lib") 
// Die Ausrichtung der Abschnitte in der PE-Datei soll an 
// 512-Byte-Grenzen erfolgen 
#pragma comment(linker, "/OPT:NOWIN98") 
// #pragma comment(linker, "/nodefaultlib:libc.lib") 
// #pragma comment(linker, "/nodefaultlib:libcmt.lib") 
// Bei allen Benachrichtigungen wird die DllMain aufgerufen 
extern BOOL WINAPI DllMain( 
                           HANDLE  hDllHandle, 
                           DWORD   dwReason, 
                           LPVOID  lpreserved 
                           ) ; 
// 
// Geänderte Version des Startercodes von Visual C++. Vereinfacht, 
// damit er leichter lesbar wird. Unterstützt nur ANSI-Programme. 
// 
extern "C" 
BOOL WINAPI _DllMainCRTStartup( 
                               HANDLE  hDllHandle, 
                               DWORD   dwReason, 
                               LPVOID  lpreserved 
                               ) 
{ 
    if ( dwReason == DLL_PROCESS_ATTACH ) 
    { 
        // installiere unsere kleine atexit-Tabelle 
        _atexit_init(); 
        // rufe C++-Konstruktoren auf 
        _initterm( __xc_a, __xc_z ); 
    } 
    BOOL retcode = DllMain(hDllHandle, dwReason, lpreserved); 
    if ( dwReason == DLL_PROCESS_DETACH ) 
    { 
        _DoExit(); 
    } 
    return retcode ; 
}

Die atexit-Funktion überprüft, ob es in diesem Array noch einen freien Platz gibt, und trägt den neuen Zeiger in diesem Fall am Ende der Liste ein. Eine etwas flexiblere Version dieses Codes würde das Array dynamisch verwalten und bei Bedarf noch vergrößern. Die Funktion _DoExit schließlich delegiert den Gang durch die Liste an _initterm¸ die natürlich auch die entsprechenden Funktionen aufruft. In einer idealen Welt würde _DoExit das Array von hinten nach vorne durchlaufen, also in umgekehrter Reihenfolge, wie es die Implementierung aus der Laufzeitbibliothek von Visual C++ vormacht. Allerdings soll die LIBCTINY in erster Linie nicht perfekt, sondern möglichst klein sein. Also spart sie sich diesen Aufwand.

Die minimalistischen Starterfunktionen der LIBCTINY

Wenden wir uns nun den Änderungen in der LIBCTINY zu, die für die Entwicklung von DLLs vorgenommen wurden. Wie bei den EXEs besteht auch hier der Trick darin, den Code für den DLL-Eintrittspunkt so klein wie möglich zu halten und alle nicht unbedingt erforderlichen Funktionsaufrufe zu vermeiden, die sonst nur große Mengen von zusätzlichen Code einschleppen würden. Listing L4 zeigt den minimalistischen Startercode für DLLs. Wenn Ihre DLL geladen wird, ruft das System zuerst diesen Code auf und nicht Ihre DllMain.

L5 TEST.CPP, ein kleines Testprogramm

// Ein kleines Testprogramm für die TINYCRT. Es macht eigentlich  
// nichts sonderlich Sinnvolles. 
// 
#include <windows.h> 
#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
int main( int argc, char *argv[] ) 
{ 
    int i; 
    for ( i = 0; i < argc; i++ ) 
    { 
        printf( "argc: %u \'%s\'\n", i, argv[i] ); 
    } 
    char * p = new char[10]; 
    lstrcpy( p, "Hello" ); 
    delete p; 
    printf( "%s\n", strlwr( "MyLowerCaseString" ) ); 
    printf ( "strcmpi: %u\n", strcmpi( "Abc", "abc" ) ); 
    strrchr( "foo", 'o' ); 
    return 0; 
} 
// Definiere eine einfache C++-Klasse mit einem Konstruktor 
class TestClass 
{ 
public: 
    TestClass(void) 
    { 
        printf( "In TestClass constructor\n" ); 
    } 
    ~TestClass(void) 
    { 
        printf( "In TestClass destructor\n" ); 
    } 
}; 
// Lege eine globale Instanz dieser Klasse an 
TestClass g_TestClassInstance;

Die Funktion _DllMainCRTStartup ist der Ort, an dem die Ausführung des DLL-Codes beginnt. In der LIBCTINY-Implementierung überprüft diese Funktion, ob die DLL gerade ihren DLL_PROCESS_ATTACH-Aufruf erhalten hat. In diesem Fall ruft sie die bereits beschriebene _atexit_init auf und gibt mit _initterm allen statischen Konstruktoren eine Chance. Wichtigster Teil dieser Funktion ist der Aufruf von DllMain. Das ist die Funktion, die Sie bisher immer als Eintrittspunkt in Ihre DLL betrachtet haben. Der DllMain-Aufruf erfolgt bei allen vier Benachrichtigungstypen (process attach/detach, Thread attach/detach).

Zum Schluss überprüft die DllMainCRTStartup noch, ob sich die DLL in der DLL_PROCESS_DETACH-Phase befindet. In diesem Fall ruft sie _DoExit auf. Wie schon beschrieben bewirkt dies den Aufruf der statischen Destruktoren. Falls Sie nun auf den Startercode für die Konsolen- und UI-EXEs neugierig geworden sind, schauen Sie sich die Dateien CRT0TCON.CPP und CRT0TWIN.CPP an. Sie finden diese Dateien auf der Begleit-CD dieses Hefts.

In der DLLCRT0.CPP (Listing L4) ist übrigens auch noch diese Zeile gegen Anfang des Listings einer Erwähnung wert:

#pragma comment(linker, "/OPT:NOWIN98")

Dadurch gelangt die Anweisung für den Linker in die Datei DLLCRT0.OBJ, den Schalter /OPT:NOWIN98 zu benutzen. Sie brauchen diesen Schalter also nicht mehr von Hand in Ihrer Make-Steuerdatei oder in der Projektdatei anzugeben. Ich gehe einfach davon aus, dass Sie diesen Schalter sowieso benutzen möchten, wenn Sie schon zur LIBCTINY greifen.

Die LIBCTINY.LIB in der Praxis

Der Umgang mit der LIBCTINY ist sehr einfach. Sie brauchen sie einfach nur in die Liste mit den Bibliotheksdateien aufzunehmen, die der Linker durchsucht. Wenn Sie in der Visual Studio-IDE arbeiten, tragen Sie die Bibliothek auf der Seite Projekte / Einstellungen / Linker ein. Dabei spielt es keine Rolle, an welcher Art von Binärdatei Sie gerade arbeiten (Konsolen-EXE, GUI-EXE oder DLL), da die LIBCTINY.LIB für jede dieser Programmarten entsprechende Eintrittspunkte enthält.

Schauen Sie sich das kleine Testprogramm TEST.CPP in Listing L5 an. Dieses Programm ruft einfach ein paar Funktionen auf, die in der LIBCTINY.LIB implementiert werden, und sorgt noch für den Aufruf eines statischen Konstruktors und Destruktors. Wenn ich diese Datei mit Visual C++ 6.0 kompiliere, ist die resultierende ausführbare Datei normalerweise 32.768 Bytes groß:

CL /O1 TEST.CPP

Durch die simple Ergänzung der Kommandozeile um die LIBCTINY.LIB schrumpft die resultierende ausführbare Datei auf 3072 Bytes zusammen:

CL /O1 TEST.CPP LIBCTINY.LIB

Vielleicht fragen Sie sich schon, welche Funktionen aus der Laufzeitbibliothek LIBCTINY nicht implementiert. Was TEST.CPP betrifft, ist dies zum Beispiel der Aufruf von strrchr. Daraus ergibt sich aber kein Problem, weil diese Funktion in der regulären LIBC.LIB oder LIBCMT.LIB vorhanden ist, die Visual C++ anbietet. Sowohl die LIBCTINY.LIB als auch die LIBC.LIB implementieren eine Reihe von Funktionen, wobei das Angebot von LIBCTINY.LIB aus nahe liegenden Gründen kleiner als das der LIBC.LIB ist. Wichtig ist für unsere Zwecke eigentlich nur, dass der Linker die benötigten Funktionen zuerst in der LIBCTINY.LIB sucht, bevor er sich bei einem Misserfolg an die LIBC.LIB wendet. Also werden die Funktionen aus der LIBCTINY.LIB tatsächlich den regulären Implementierungen aus der LIBC.LIB vorgezogen. Und sollte die LIBCTINY.LIB die gewünschte Funktion nicht bieten können, so kommt eben die LIBC.LIB zum Zuge.

Zum Abschluss möchte ich noch einmal wiederholen, dass sich die LIBCTINY.LIB nicht für alle Zwecke eignet. Wenn Ihr Code zum Beispiel mehrere Threads einsetzt und auf die Thread-Unterstützung aus der Laufzeitbibliothek angewiesen ist, dann wäre die LIBCTINY.LIB schlicht die falsche Wahl. Ich probiere den neuen Kandidaten in solchen Fällen einfach mit der LIBCTINY.LIB aus. Wenn alles klappt, umso besser! Wenn nicht, verwende ich eben die normale Laufzeitbibliothek.

Kleiner Nachtrag zum Metadaten-Artikel

In meinem Artikel "Metadaten in der .NET-Umgebung" (System Journal 01/2001, S. 47) sagte ich, dass die #import-Direktive den Compiler veranlasst, die betreffende COM-Typbibliothek einzulesen und die ATL-fähigen Header-Dateien für alle darin enthaltenen Schnittstellen zu generieren. Während die Header-Dateien tatsächlich durch die import-Direktive generiert werden, stellte sich aber heraus, dass sie kein ATL verwenden.

Richard Grimes, Autor des Buches "Professional ATL COM Programming" (Wrox Press, 1998), wies mich freundlicherweise darauf hin, dass die #import-Direktive die Generierung von "COM-Hilfsklassen" bewirkt, wie Microsoft diese Konstrukte nennt, die von der Header-Datei COMDEF.H unterstützt werden. Weiterhin teilte mir Richard mit: "Es gibt zwischen diesen COM-Hilfsklassen und ihren ATL-Äquivalenten viele Unterschiede. Der wichtigste ist wohl, dass die ATL keine C++-Ausnahmen einsetzt. Tatsächlich sind die ATL-Klassen nicht ganz so schwergewichtig wie die COM-Hilfsklassen. Daher hätte ich es lieber gesehen, wenn sich Microsoft dazu durchgerungen hätte, den Compiler den entsprechenden ATL-Code generieren zu lassen."

Nun, ich muss zugeben, dass ich mir diese Umstände wohl etwas genauer hätte ansehen sollen, bevor ich meinen Artikel schrieb. Meine eigene Erfahrung mit ATL beschränkt sich im Wesentlichen auf die Wizards von Visual C++ und der Änderung des generierten Codes. Gelegentlich habe ich auch die #import-Direktive eingesetzt, aber wohl nicht oft genug, um von mir aus zu dem Schluss zu kommen, dass es sich beim resultierenden Code nicht um ATL handelt. Daher möchte ich mich bei Richard für den Hinweis bedanken und für die implizite Anregung, meine Aussagen noch genauer zu prüfen.