WildMag-Archiv

Sie sind hier: Diskmags → WildMagWildMag #4 (Mai 2001) → Win32-Assembler-Tutorial 4k

Win32 Assembler Tutorial Chapter 4k
TS

So zutreffend wie dieses Mal war der Titel dieses Tutorials noch nie: 4k alias 4096 Bytes sind eine Datenmenge, die in dem aktuellen Kapitel überall auftritt: Zuerst einmal ist sie die Größe der Speicherseitentabellen der x86er CPUs, welche in allen aktuellen 32-Bit-Betriebssystemen für Speicher- und Zugriffsschutz sowie das Auslagern des Speichers auf die Festplatte verwendet werden - natürlich auch in Win32. Zweitens sind 4k die maximale Programmgröße in den gleichnamigen Wettbewerben, was zu den Größenoptimierungstricks im zweiten Abschnitt paßt. Zu guter letzt organisiert auch das Dateisystem, welches im letzten Abschnitt angeschnitten wird, die Dateien meist in Blöcken von 4096 Bytes.

Weil es diesmal um mehrere kleinere Themen geht, statt um ein großes, gibt es diesmal kein Beispielprogramm. Macht aber nix, im Text gibt es genügend Beispielcode als auch wie gewohnt in der ZIP-Datei (siehe Bonus-Verzeichnis).

Dreigängemenü:

- Programmsegmente, Speicherseiten, DLLs und das PE Dateiformat
- Win32 spezifische Dateigrößenoptimierungstricks
- Mehr über Dateien

Das PE Dateiformat

Das PE (Portable Executable) Format wird für Dateien wie .exe, .dll, .scr, .cpl usw. verwendet. Es besteht aus den Dateiköpfen (Header) und den einzelnen Sektionen.

Folgende Header werden (in genau dieser Reihenfolge) verwendet:

Ein MS DOS Exe-Dateikopf (64 Bytes).
Für Win32 sind nur zwei Einträge von Bedeutung: Die ersten beiden Bytes beinhalten die Zeichenkette "MZ", welche eine (unter DOS) ausführbare Datei identifizieren, die letzten 4 Bytes zeigen auf den Anfang des PE-Headers.
Den Rest der DOS-Exe-Datei.
Dieser Teil wird auch als "DOS stub" bezeichnet, er wird nur unter DOS ausgeführt. Normalerweise tut er nicht viel außer die Meldung "This file requires Windows" auszugeben.

Der PE Header, bestehend aus der PE-Kennung (00004550h), welche angibt, daß die Datei eine Win32-PE-Datei ist, sowie dem 20 Byte großen eigentlichen PE Header. Er beinhaltet Informationen über die CPU, für die das Programm erstellt wurde, wie viele Abschnitte (sections) in der Datei stecken, die Größe des auf den PE-Headers folgenden "optional header" als auch weitere Informationen, die für Debugger interessant sind.

Der optionale Header, der trotz seines Namens bei Win32-PE-Dateien auf jeden Fall vorhanden sein muß. Er enthält Informationen über Größe und Ort der Sections in der Datei, über nicht initialisierte Datensegmente, die Ausrichtung der Segmente im Speicher und in der Datei sowie welche Betriebsystemversion erforderlich ist. Ebenso ist dort gekennzeichnet, wieviel Hauptspeicherplatz die Datei nach dem Laden benötigt, über die Speichermenge, die für den Stack und den Heap reserviert werden soll, die Gesamtgröße aller Header zusammen, was mit dem Offset zu den Sektionen identisch ist, eine Kennzeichnung, ob die Datei einen DLL-Einsprungspunkt enthält, die Addresse des Einsprungpunktes, die Startaddresse, Image Base genannt, wo sich die Datei nach dem Ladevorgang befindet, und die Größe des "data directories" (derzeit 16 Einträge groß).
Der Rest des optionalen Headers besteht aus dem data directory. Dieses Verzeichnis beinhaltet Informationen, die dem Betriebsystem mitteilen, wo sich die zur Initialisierung des Programmes notwendigen Daten nach dem Laden der Datei im Speicher befinden. Der PE-Loader benutzt diese Tabelle, weil es nicht möglich ist, anhand des Namens oder der Position eines Bereiches dessen Zweck zu bestimmen (mehr darüber weiter unten). Jeder Eintrag enthält die Größe und den Offset des dazugehörigen Bereiches. Nichtbenützte Einträge werden mit 0 gefüllt. Die Daten, die durch diese Tabelle bestimmt werden, sind die Importierten Addressen/Funktionen, Exportierten Addressen/Funktionen, Ressourcen sowie einige weniger gebräuchliche Daten.

Puh, bis jetzt sind schon (64 + ?) + (4 + 20) + (96 + 16*8) = 312+ Bytes nur vom Dateikopf belegt, und das meiste davon ist mehr oder weniger nutzlos oder wird überhaupt nicht verwendet.

Die Sections

Die Sektionen sind das Interessanteste an einer PE-Datei. Der gesamte Code, die Daten, Imports/Exports und/oder Ressourcen stecken in ihnen. Jede Sektion besteht aus zwei Teilen: Dem Section Header und den Daten der Sektion selbst.

Jeder Section Header ist 40 Bytes groß und aufeinanderfolgend angeordnet. Sie befinden sich direkt hinter dem optionalen Header. Sie enthalten den Name der Sektion, der jeder gewünschte Name sein kann, der maximal 8 Zeichen lang ist (unbenützte Zeichen werden mit Nullen gefüllt). So gibt es nichts, was einen daran hindert, seine Codesection .badcode oder die Ressourcesection blah zu nennen. Der Section Header enthält desweiteren die Größe und den Offset der Sektion in der Datei und im Speicher. Der letzte interessante Teil des Section Headers besteht aus den Flags (32 Bits), welche die Zugriffsrechte auf die Sektion angeben:

Code
Die Sektion enthält Programmcode.

Initialized data
Die Sektion enthält initialisierte Daten.

Uninitialized data
Die Sektion enthält uninitialisierte Daten (üblich in Verbindung mit einer Größe von 0 in der Datei).

Shared
Das erste wichtige Flag. Eine als shared gekennzeichnete Sektion wird nur einmal in den Speicher geladen, bei jedem weiteren Laden der PE-Datei werden nur die Einträge der Seitentabellen entsprechend gesetzt, so daß sie auf den gleichen Speicherbereich zeigen. Ist das Flag nicht gesetzt, wird für die Sektion jedesmal ein neuer Speicherblock belegt. Insbesondere für Dateien, die von vielen Prozessen verwendet werden (üblicherweise DLL-Dateien), ist das Setzen dieses Flags eine gute Idee um den Ladevorgang zu beschleunigen und den Speicherverbrauch zu reduzieren.

Executable
Die Sektion ist ausführbar. Macht eigentlich nur in Verbindung mit dem Code-Flag Sinn.

Readable
Erlaubt lesende Zugriffe auf die Sektion.

Writable
Erlaubt schreibende Zugriffe auf die Sektion (Vorsicht bei gleichzeitiger Verwendung von Shared).

Es fällt auf, daß es kein Flag gibt, das angibt, welchen Zweck eine Sektion erfüllt. Jedes Segment im Quellcode erzeugt üblicherweise eine Sektion, weitere Sektionen werden für den Import und Export externer Funktionen und Variablen sowie für dem Programm hinzugefügte Ressourcen verwendet. Eine PE-Datei besteht aus mindestens einer Sektion (sonst wäre sie leer) und kann soviele Sektionen haben wie es der Speicherplatz erlaubt.

Der Speicherplatz, den eine Sektion in der Datei belegt, entspricht der Größe der Sektion aufgerundet auf das nächste Vielfache des File Alignment-Feldes, das im Header definiert ist. Der Wert dieses Feldes ist eine Zweierpotenz zwischen 512 und 65536. Folglich belegt jede Sektion mindestens 512 Bytes in der Datei, selbst wenn sie nur ein einziges Byte enthält. Die einzigen Ausnahmen sind die letzte Sektion der Datei, bei welcher der ungenützte Teil weggelassen werden darf sowie komplett uninitialisierte Sektionen, für die keine Daten in der Datei gespeichert werden müssen.

Es gibt ein weiteres Feld, welches die Ausrichtung der Sektionen angibt: Object Alignment. Dieser Wert muß ein Vielfaches von 4096 sein, der Größe der Speicherseiten der x86-Prozessoren, und darf nicht kleiner als das File Alignment sein. Die Ausrichtung an der Größe der Speicherseiten liegt darin, daß das Betriebsystem die Zugriffsrechte praktisch vollständig über die Speicherseiten abwickelt.

Um eine PE-Datei klein zu halten, setze man das FileAlignment auf 512 Bytes; um sie etwas schneller zu laden setzt man FileAlignment und ObjectAlignment auf 4096 Bytes. Im letzteren Falle muß der PE-Loader die einzelnen Sektionen nicht auf die nächste ObjectAlignment-Grenze aufblasen, sondern kann sie direkt in den Speicher laden, da sie schon die richtige Größe haben.

Die meisten PE-Dateien nützen vordefinierte Namen für die Sektionen. Diese verwenden für bestimmte Zwecke jeweils bestimmte Namen, obwohl ihr Verhalten natürlich weiterhin von den Einträgen im PE-Header und/oder dem Data Directory bestimmt wird. Folgende Bezeichnungen sind üblich:

Die code section, meist .code oder .text genannt.
Eine Sektion mit Programmcode erkennt man meist am BaseOfCode-Feld im optionalen Header. Sie ist bei Anwendungen immer nötig, aber nicht zwingend für DLLs (falls die DLL nur Daten oder Ressourcen enthält).
In einer Anwendung enthält diese Sektion auch den Einsprungspunkt. Für DLLs ist der Einsprungspunkt optional. Falls einer angegeben wurde, wird er jedesmal aufgerufen, wenn die DLL einem Prozess hinzugefügt wird, von einem Prozess entfernt wird und ggf. auch wenn in dem Prozess Threads erzeugt oder beendet werden. Falls die Datei externe Funktionen verwendet, z.B. durch die EXTERN-Anweisung im Quellcode angefordert, enthält diese Sektion auch die Addressen der importierten Funktionen. Diese liegen oft vor dem Einsprungspunkt und, abhängig vom verwendeten Linker/Assembler/Compiler, haben den Opcode für einen jmp near vorangestellt. Um die Funktion aufzurufen, ruft der Code entweder den Sprung auf die Addresse auf, welcher dann in die Funktion springt oder die Addresse wird direkt in einem Call verwendet.
Die meisten Linker setzen die Zugriffsrechte für die Codesection auf Code + Shared + Executable + Readable. Um selbstmodifizierenden Code zu verwenden zu können muß zusätzlich das Writable-Flag gesetzt und das Shared-Flag gelöscht werden.

Eine data section, oft .data genannt mit den Zugriffsrechten Readable + Writable, initialisierte Daten beinhaltend.

Eine unitialized data section oft als .udata oder .bss bezeichnet mit den Eigenschaften Readable + Writable, üblicherweise nur aus dem Section Header bestehend, da keine initialisierten Daten enthaltend.

Eine read-only data section, oft .rdata genannt. Fast identisch mit .data, aber ohne Schreibzugriffsrechte, aber dafür meist mit Shared versehen.

Eine Section mit Ressourcen, genannt resource oder .rsrc, typischerweise als Readable und Shared markiert, die Ressourcen enthaltend.

Eine exports oder .edata Section welche die Informationen enthält, mit der Funktionen und Daten, die nicht in den Ressourcen sind, anderen Programmen zugänglich gemacht werden. Diese Section ist typisch für DLLs, bei Anwendungen wird sie dagegen kaum verwendet.

Eine imports oder .idata Section. Jede zu importierende Funktion oder Daten einer anderen PE-Datei, meist DLLs, welche nach dem Laden der Datei verfügbar sein soll, ist hier mit einem Eintrag vertreten. Jede Datei mit zu importierenden Funktionen erhält hier einen Eintrag, der letzte Eintrag wird dadurch gekennzeichnet, daß er auf 0 gesetzt ist. Jeder Eintrag zeigt auf eine Tabelle, die auf die Namen der jeweiligen Funktionen/Addressen verweist (die Namen müssen mit ihrem Gegenstück in der anderen PE-Datei übereinstimmen) sowie der Addresse in der die importierte Funktion/Addresse gespeichert wird (meist in der .data oder der .code Section). Die Namen oder Ordnungsnummern der zu importierenden Funktionen/Addressen werden ebenfalls in der imports Section gespeichert.

Eine relocation Section .relocs, für den Fall das die Datei nicht an die Standardbasisaddresse geladen werden kann. Diese Section macht ohne Exports wenig Sinn.

Weitere Anmerkungen zu PE-Dateien, wie sie in den Speicher abgebildet werden und über die Speicherseiten zur Speicherverwaltung.

Die Sektionen können auf beliebige Art und Weise angeordnet sein. Das PE-Format erfordert nicht einmal, daß die Reihenfolge der Section Header mit der Reihenfolge der Sections in der Datei übereinstimmt.

Die oben genannten Sektionen werden von den meisten Compilern/Linkern verwendet. Dies ist allerdings nicht zwingend nötig. Laut der Definition des PE-Formats enthalten die Sektionen einfach nur irgendwelche Daten, die entsprechend der Beschreibung im Section Header in den Speicher geladen werden. Wofür sie benützt werden wird dagegen an verschiedenen Stellen im PE-Header beschrieben.
Aus diesem Grund kann man auch mehrere Sektionen in eine einzige packen.

Es gibt auch eine API-Funktion namens VirtualProtect, mit der man die Zugriffsrechte zu einem bestimmten Speicherbereich ändern kann. Per GlobalAlloc oder LocalAlloc angeforderter Speicher hat standardmäßig die Zugriffsrechte Readable + Writable + Executable, so daß man in diesen auch ohne weiteres Code hineinkopieren und ausführen kann.

Weil der lineare Addressraum eines Prozesses auf beliebige Weise auf den physikalischen Speicher abgebildet werden kann schlagen Speicheranforderungen nie wegen Fragmentierung des physikalischen Speichers fehl. Allerdings kann der lineare Addressraum ebenfalls fragmentiert werden. Deshalb sollte man zuerst Speicher anfordern, der die gesamte Zeit belegt wird und dann erst Bereiche, die bald wieder freigegeben werden. Falls ein schon belegter Speicherblock in der Größe verändert werden soll, sollte man dem Speichermanager die Möglichkeit geben, diesen verschieben zu können, entweder in dem man ihn zuerst per Unlock entsperrt und dann per Lock wieder fixiert (Lock bei Speicher ist praktisch dasselbe wie ein Lock auf Surfaces oder Buffer in DirectX und gibt in allen Fällen einen Zeiger auf den gesperrten Speicherbereich zurück) oder indem man die GlobalReAlloc/LocalReAlloc Funktion folgendermaßen verwendet:

;Für MASM und TASM: dword durch dword ptr oder
;large ersetzen
push dword GMEM_MOVEABLE
;dieses Flag erlaubt der Speicherverwaltung, den
;Speicherblock an eine andere Stelle kopieren zu können
push dword NewSizeOfMemoryBlock
;die neue Größe des Speicherblocks
push dword MemoryHandle
;das Handle wurde geliefert von GlobalAlloc oder GlobalReAlloc
call [GlobalReAlloc]
;für MASM und TASM sind die Klammern zu entfernen

Ebenso kann mit per LocalAlloc/LocalReAlloc erhaltenem Speicher verfahren werden. Die Funktion gibt ein neues Handle für den Block zurück, welches zugleich die Adresse des Blocks ist. Von nun an ist das neue Handle zu benützen, da die Position des Blocks sich verändert haben kann. Übrigens gibt es keinen Unterschied zwischen Globalen und Lokalem Speicher mehr in Win32, da sich alles in einem einzigen 4GB großen Addressraum abspielt.

Die Adresse im linearen Addreßraum eines Prozesses, an die eine PE-Datei abgebildet wird ist durch das ImageBase-Feld im PE Header gekennzeichnet. Jedoch kann es vorkommen, daß diese Addresse schon belegt ist, so daß die PE-Datei anderswohin geladen werden muß. Die Relokationsinformationen werden dann benötigt, um die betreffenden Addressen neu zu berechnen. Bei Anwendungen die keine Funktionen oder Daten exportieren wird die Relokation nicht benötigt, da diese immer als erstes in ihren Adressraum geladen werden (mit einer Ausnahme: Bei einer Basisaddresse unterhalb 4MB wird die Datei immer an die Standardadresse 4MB reloziert). Deshalb sind die Relozierungsinformationenn in einer Anwendung nicht nötig. DLLs dagegen werden oft reloziert, da vor ihnen schon einige andere Daten den Adressraum belegen und die vorgegebene Basisadresse oft schon belegt ist.

Einige Linker produzieren anscheinend Probleme bei der Relozierung. Indem man mit der Option -base ImageBase eine neue Basisaddresse setzt kann man das Problem aber meist lösen.

Größenoptimierung für Win32

Die einfachste Methode eine ausführbare Datei zu verkleinern ist ein EXE-Packer. Allerdings funtionieren diese bei PE-Dateien mit weniger als 10k nicht allzu gut. Eine andere Methode ist es, einen Dropper zu verwenden, ein Programm, das nichts anderes macht als eine Datei auf die Platte zu schreiben und es zu starten. Wenn der Dropper unter DOS läuft klappt dies besser, da der Header mitkomprimiert werden kann. Beide Varianten ändern an der ursprünglichen Dateigröße jedoch nichts und werden hier auch nicht weiter behandelt.

Der erste Angriffspunkt ist die Struktur einer PE-Datei. Der gesamte PE Header (DOS MZ Header + DOS Code + PE Headers + Section Headers) wird mindestens zur nächsten 512-Byte-Grenze aufgeblasen. Aus diesem Grunde ist eine PE-Datei, noch bevor die Sektionen enthalten sind, mindestens 512 oder 1024 Byte groß. Der kleinste Wert für Section Alignment in der Datei ist 512 Bytes, so daß jede Sektion die Datei um mindestens 512 Bytes vergrößert. Die einzige Ausnahme ist die letzte Sektion, die nicht auf eine 512-Byte-Grenze erweitert werden muß, deshalb sollte die kleinste Sektion die letzte sein.

Solange man nicht den ganzen Header umorganisieren will, ist die einzige Möglichkeit zur Verkleinerung einer PE-Datei so wenige Sektionen wie möglich zu verwenden. Es ist sogar möglich, die Imports und Ressourcen mit dem Code und den Daten in ein- und dieselbe Sektion zu stecken. Code und Daten sind in derselben Section wenn der Quellcode für Daten und Code dasselbe Segment verwendet (genau wie bei Codesegment-Variablen). Und da DS,ES und SS den gleichen Speicherbereich wie CS verwenden wird nicht mal ein CS-Präfix benötigt. Diese Methode funktioniert mit jedem Assembler und Linker. Ob auch die Imports in derselben Section landen hängt entweder von der verwendeten Importbibliothek (*.lib) oder dem Assembler ab, je nachdem, wie die Imports eingebunden wurden. Bis jetzt ist mir kein Linker bekannt, der Ressourcen nicht in ein eigenes Segment steckt, allerdings kann man auf Ressourcen auch verzichten. Anwendungen werden üblicherweise nie reloziert, deshalb kann man die Relokationsinformationen problemlos aus der .EXE-Datei rauswerfen (einige Linkers machen dies schon selbst).

Tip für Hardcore-Coder: Der aufgefüllte Bereich des PE Headers, der Code des DOS Stubs sowie alle anderen aufgefüllten Bereiche werden vom PE-Loader überhaupt nicht verwendet, so daß man dort wunderbar Daten und Code hineinstopfen kann. Die Code/Datasection vollzustopfen ist trivial, man braucht nur das entsprechende Segment im Quellcode zu füllen bis die Ausrichtungsgrenze erreicht ist. Um die restlichen leeren Felder (einige Hundert Byte) zu füllen, sollten die Sektionen groß genug gesetzt sein und die Zugriffsrechte passen. Am einfachsten ist es, die Datei erneut in den Speicher zu laden, so daß man sich nicht mit Zugriffsrechten beschäftigen braucht, dies erfordert aber relativ viel Code. Oder man greift einfach auf das Speicherabbild zu (Der Header liegt direkt an der Basisaddresse), man muß aber beachten, daß die Datei nun dem ObjectAlignment entsprechend im Speicher liegt, so daß die Position im Speicher nicht diesselbe wie in der Datei ist.

Die zweite Methode zur Größenoptimierung ist dieselbe wie unter DOS: Den Code so kompakt wie möglich zu halten, indem man jedes eliminierbare Byte entfernt. In diesem Tutorial werden nur speziell unter Win32 mögliche Tricks besprochen, die anderen sind dieselben wie unter DOS, es empfiehlt sich folglich, auch andere Tutorials zur Größenoptimierung zu lesen und, besonders wichtig, den kompletten x86-Befehlssatz gut kennenlernen und ein Gefühl dafür bekommen, welche und wieviele Opcode-Bytes eine Instruktion in Assembler erzeugt.

Einer der Bytefresser in Win32 sind Funktionsaufrufe. Push-Befehle auf den Stack sollten so kompakt wie möglich sein, am besten indem man Register verwendet. Viele Funktionsparameter sind auf 0 gesetzt. Solange ein Register auf 0 gesetzt ist lassen sich diese Parameter mit jeweils einem Byte erledigen. Die Instruktion zum Setzen eines Registers auf 0 braucht weniger Bytes als später durch die Verwendung des Registers als Push-Operand eingespart werden (z.B. benötigt xor eax,eax nur 1 Byte). Manchmal lassen sich auch die Aufrufe selbst optimieren. Liegt eine Konstruktion in folgender Form vor:

call [function_address]
ret

kann man diese (wie vielleicht schon bekannt ist) durch folgenden Code ersetzen:

jmp [function_address]

Als nächstes werfen wir mal einen Blick auf den Einsprungspunkt des Programms. Seine Definition lautet:

WinMain(hInstance, hPrevInstance, lpszCmdLine, nCmdShow);

Jedes Programm ist also nichts anderes als eine normale Funktion mit folgenden Parametern auf dem Stack:

[esp+16]: nCmdShow
;dieser Wert gibt an, ob das Programmfenster normal,
;minimiert oder maximiert angezeigt werden soll
[esp+12]: lpszCmdLine
;Speicheraddresse der letzten Kommandozeileneingabe
[esp+8]: 0
;in Win32 immer auf 0 gesetzt
[esp+4]: hInstance
;Instance Handle des aktuellen Prozesses
[esp+0]: stacked EIP
;Rücksprungaddresse

Zwei Funktionsaufrufe lassen sich mit diesem Wissen ersetzen. Das für die Erzeugung eines Fensters benötigte Instance Handle kann direkt von [esp+4+4*x] gelesen werden, wobei x die Anzahl der seit Programmstart auf den Stack gepushten und nicht wieder entfernten Dwords ist. Auf diese Weise spart man den Aufruf und Import von GetModuleHandle ein. Die zweite ersetzbare Funktion ist ExitProcess am Programmende. Eine einfacher ret - Befehl beendet das Programm ebenfalls (ist logisch: eine Funktion endet immer mit ret ;-). Darauf aufbauend der Anwärter für das Guinness-Buch der Rekorde, der kürzeste Code eines Win32-Programmes:

Programmeinsprungspunkt: ret
end Programmeinsprungspunkt

Der Code ist genau ein Byte groß! (Den Stack braucht man nicht abzuräumen, wenn sich das Programm beendet wirft ihn das Betriebsystem sowieso fort.) Eventuell störend ist, daß DLLs nicht informiert werden, bevor sich das Programm beendet, was aber für 4k-Code meist kein Problem darstellt.

Ein anderer Vorteil von Win32 besteht darin, daß beim Beenden eines Programmes alle noch offenen Handles (Speicher, Dateien,...) automatisch geschlossen werden. Man muß sie also nicht unbedingt selber freigeben. Solange Größenoptimierung nicht das Hauptziel ist sollte man aus Gründen der Performance jedoch lieber die Handles sobald möglich von Hand freigeben.

Falls DirectX (oder andere Funktionen, die per COM Syntax aufgerufen werden) benützt wird erzeugt die Verwendung eines Makros wie das in den letzten beiden Tutorials verwendete jedesmal 20 Bytes an Code. Wenn man das Makro aber durch:

DXcall:
push edx
mov edx,[edx]
add edx,ecx
mov edx,[edx]
jmp edx

ersetzt und die Verwendung des Makros folgendermaßen vornimmt:

mov ecx, MethodToUse
mov edx, InterfaceToUse
call DXcall

verringert sich der Platzbedarf enorm.

Bei 32 Bit Code sind die Register standardmäßig 8 oder 32 Bit groß. Speicheraddressen sind immer 4 Byte groß. Einige arithmetische Instruktionen erlauben es, eine 4 Byte Immediate-Zahl (d.h. eine Zahl welche Teil einer Instruction wie in add eax,8 ist) als ein einzelnes vorzeichenbehaftetes Byte zu speichern, solange sie in ein einzelnes vorzeichenbehaftetes Byte paßt. MASM und TASM benützen diese Kurzform standardmäßig überall wo sie möglich ist, während NASM immer die 4-Byte-Form generiert. In diesem Fall wird in NASM eine Byte-Präfix benötigt, um die kürzere Form zu erhalten:

add eax, byte 8

Wenn man nur einen Teil eines Dword-Operanden ändert, verringert sich die Größe der Immediates ebenfalls. Allerdings spart man nur ein Byte ein, wenn man statt auf ein Dword ein Word verändert, da das 16 Bit-Operandengrößenpräfix ein eigenes Byte benötigt.

Das Belegen von Speicher kann ebenfalls verkleinert werden. Statt dazu GlobalAlloc oder LocalAlloc zu verwenden lässt sich Code ebenfalls einsparen, wenn man den Speicher auf dem Stack anlegt. Dies ist mit dem Anlegen lokaler Variablen auf dem Stack identisch:

sub esp,Speicherblockgröße
;man denke daran, daß der Stapel nach unten wächst
;esp zeigt nun auf den belegten Speicherblock

Wenn man den so belegten Speicher wieder freigeben muß (eigentlich nur nötig wenn eine ret-Instruction folgt) nimmt man:

add esp,Speicherblockgröße

Von besonderer Bedeutung im Zusammenhang mit dem Stack sind zwei Felder des PE-Headers: StackReserveSize und StackCommitSize. Das erste gibt an, wie groß der Stapel maximal werden kann ohne andere Speicherbereiche im Addressraum des Prozesses zu überschreiben. Dazu wird die entsprechende Speichermenge in den Seitentabellen als vorbelegt markiert. Das zweite gibt die Menge an physikalischem Speicher an, der schon für den Stack in den Addreßraum gemappt wurde.

Wenn man den Stack zur Reservierung größerer Speicherblöcke verwendet, sollte zumindest der StackReserve-Parameter groß genug sein, um den gesamten reservierten Speicher, die maximale Menge auf dem Stack zwischengespeicherter Parameter sowie eine Sicherheitsreserve zu enthalten. Das Programmläuft etwas schneller, wenn StackCommit so groß wie die maximal erwartete Stackgröße (mit darauf angelegtem Speicher) ist. Aber man sollte auch daran denken, daß man den Stack nicht zu groß machen sollte, da sonst der Speicher woanders fehlt (der freie Addreßraum eines Prozesses beträgt 2 oder 3 GB minus der Größe der gemappten Datei und der Stackgröße).

Das Anfordern von Speicher vom Stack hat den Nachteil, daß sich der Speicherblock weder vergrößern noch freigeben lässt, solange noch Daten auf dem Stack liegen, die nach dem Belegen des Speicherblocks angelegt wurden.

Ein weiteres häufiges Problem ist, daß einem die Register ausgehen, so daß man auf Speichervariablen ausweichen muß. Eine im Code oder Datensegment vordefinierte Variable braucht für den Zugriff alleine schon 5 Bytes, eine relative Addresse ist immerhin immer noch mindestens ein Byte größer als wenn man stattdessen auf ein Register zugreift. Es wäre oft schon besser, wenn man alle 8 Universalregister verwenden könnte. 8 Register? Genau, esp ist auch eines, oder etwa nicht? Es lässt sich genauso wie esi, edi oder ebp einsetzen.

Und es gibt einige praktische Anweisungen wenn man den Inhalt von esp als Speicheraddresse auffaßt: push entspricht in etwa stosd und pop ist mit lodsd vergleichbar, mit dem Unterschied daß esp immer unabhängig vom DirectionFlag bei einem push herunter- und bei einem pop heraufgezählt wird. Dafür sind sie aber wesentlich vielseitiger, weil sie fast jeden Operand als Quelle oder Ziel akzeptieren und nicht nur eax wie es bei lodsd oder stosd der Fall ist (dafür sind lodsd/stosd kleiner).

Es ist ein verbreiteter Irrglaube, daß das Verändern von esp oder des Speichers, auf den esp zeigt, das Programm zum abstürzen bringt solange man sich nicht auf push, pop, call, ret oder int beschränkt. Tatsache ist, daß nur solange esp als Stackzeiger verwendet wird esp auf die richtige Addresse zeigen muß. Es ist sogar möglich, esp auf einen anderen Speicherbereich zeigen zu lassen und zusätzlich esp immer noch als Stackzeiger zu benützen. Vorraussetzung dafür ist daß der Speicher, auf den esp zeigt, vom Programm auch angefordert oder belegt wurde und unterhalb von esp unbenützter Speicher zur Verwendung als Stack zur Verfügung steht. Man kann auch esp als Zeiger auf einen Speicherbereich verwenden, der in einer Schleife mit absteigenden Addressen gefüllt wird und dabei immer noch esp als Stackzeiger verwenden, da dabei stets der von der Schleife gefüllte Speicher oberhalb von esp liegt und der Stapel unterhalb von esp. Zusammenfassend sind also die folgenden Punkte zu beachten:

* Solange push, pop, call oder ret nicht verwendet werden, kann man esp als völlig normales Register verwenden.
* Wenn esp weiter für den Aufruf von Funktionen oder für temporäre Daten benützt werden soll ist darauf zu achten, daß der Speicherbereich, auf den esp zeigt a) Schreib-/Lesezugriff erlauben muß, b) keine Daten enthält, die später noch benötigt werden und c) für die zu stapelnden (oder zu stackenden ;-) Daten groß genug ist.
* Wenn die Daten auf dem Stack nach dem Modifizieren von esp noch benötigt werden (insbesondere bei Funktionen, die per ret beendet werden), muß esp anschließend auf den vorherigen Wert zurückgesetzt werden.

Da push, pop, call und ret nur an ihrer Position im Code auftreten, lässt sich der aktuelle Zustand von esp sowie ob die Addresse auf die esp zeigt leicht verfolgen. Hardwareinterrupts können dagegen immer auftreten und lassen sich zudem nicht per Interrupt-Flag ausmaskieren (das Interrupt-Flag kann nur im Kernel verändert werden). Das gibt in Win32 aber keine Probleme: Interruptprozeduren sind ein Teil des Kernels und/oder andere Virtueller Maschinen, aus diesem Grund wird bei einem Interrupt esp auf einen eigenen Speicherbereich gesetzt und nach dem Interrupt wiederhergestellt.

Console Apps and File I/O

Bis jetzt haben alle Beispiele des Tutorials die grafische Oberfläche verwendet. Aber es gibt auch noch Kommandozeilenprogramme für die Konsole. Die Konsole ist ein einfaches Fenster welches dazu benützt wird, um Eingaben von der Tastatur zu lesen und um Text darin auszugeben. Es ist dasselbe Fenster, welches auch für die DOS-Box verwendet wird. Jedes Programm kann eine Konsole haben (allerdings pro Programm maximal eine), der einzige Unterschied zwischen Konsolenanwendungen und GUI-Anwendungen (Graphical User Interface) besteht darin, daß Konsolenanwendungen sie nicht extra (per AllocConsole) erzeugen müssen sondern schon mit einer dazugehörigen Konsole gestartet werden. Es handelt sich nur um einen Eintrag im PE-Header, der dem PE-Loader mitteilt ob er eine Konsole anfordern soll oder nicht. Falls man eine Konsolenanwendung von einer Konsole aus startet, halten alle anderen Aktivitäten in dieser an, solange bis das Programm sich beendet.

Auf zweierlei Arten lassen sich Zeichen von der Konsole lesen bzw. ausgeben. Die sogenannten low-level-Funktionen arbeiten direkt mit dem Ein- und Ausgabepuffer der Konsole, eine zweidimensionale Cursorposition verwendend. Und es gibt die Funktionen, welche mit den Standarddateiehandles STDIN, STDOUT und STDERR arbeiten. Standardhandles lassen sich wie Dateihandles in den Funktionen zum Dateizugriff verwenden.

Ein Standardhandle erhält man entweder, indem man GetStdHandle oder CreateFile mit dem vorgegebenen Dateinamen des Standardhandles aufruft. Obwohl Konsoleanwendungen nicht sonderlich schön aussehen sind sie für Debugging- und Testzwecke äußerst praktisch. Nicht nur, daß man ein unabhängiges Fenster hat, welches sich leicht auslesen und beschreiben lässt und sich für die Besitzer mehrere Bildschirme auf den zweiten verschieben lässt, es bietet zudem die Möglichkeit, die Ein- und Ausgaben entweder in eine Datei auf der Platte oder in eine andere Standarddatei wie COM oder LPT umzuleiten. Man kann mit Hilfe der Konsole auch Daten mit einem anderen Prozess austauschen, wenn die Konsolenanwendung von einer anderen Anwendung gestartet wurde. Zu guter Letzt lässt sich eine Konsolenanwendung sehr leicht in eine GUI-Anwendung ändern welche die Ausgaben statt auf die Konsole in eine Datei auf der Festplatte schreibt. Man muß nur ein Flag im Linker anpassen und ein paar Parameter von CreateFile anpassen.

Der folgende Code liefert ein Handle für STDOUT:

;für MASM und TASM ist dword mit dword ptr oder large ersetzen
push dword STD_OUTPUT_HANDLE
;um STDERR zu benützen geht alternativ STD_ERROR_HANDLE
call [GetStdHandle]
;für MASM und TASM sind die Klammern zu entfernen
;eax enthält das gewünschte Handle oder -1 wenn etwas
;nicht funktioniert hat

Ein nicht-umleitbares Handle bekommt man per:

push dword 0
push dword FILE_ATTRIBUTE_NORMAL
push dword CreationFlag ;siehe unten
push dword 0
push dword FILE_SHARE_READ + FILE_SHARE_WRITE
;oft reicht auch 0 als Parameter aus
push dword GENERIC_WRITE
push dword address_of_filename
;identisch mit offset filename
call [CreateFileA]
;eax enthält das gewünschte Handle oder -1 im Fehlerfall

Die folgenden Dateinamen sind bei CreateFile möglich:

* CON öffnet die Konsole zur Ein- bzw. Ausgabe (abhängig davon, ob GENERIC_WRITE oder GENERIC_READ gesetzt wurde). CreationFlag muß auf OPEN_EXISTING gesetzt sein.
* CONOUT$ oder CONIN$ sind ebenfalls gültige Namen für die Konsole.
* COMx oder LPTx, um auf die entsprechenden serielle oder parallele Schnittstelle zuzugreifen. In diesem Fall muß CreationFlag auf OPEN_EXISTING gesetzt sein und FILE_SHARE_READ + FILE_SHARE_WRITE durch 0 ersetzt werden.
* Ein beliebiger zulässiger Dateiname, um auf die entsprechende Datei auf der Festplatte zuzugreifen.

Die folgenden Werte für creation flags können bei CreateFile verwendet werden, um auf eine normale Datei auf der Platte zuzugreifen:

* CREATE_NEW wenn die Datei ein einziges Mal erzeugt werden soll.
* CREATE_ALWAYS wenn die Datei bei jedem Programmstart neu erzeugt werden soll.
* TRUNCATE_EXISTING um nur auf bereits existierende Dateien zuzugreifen.
* OPEN_EXISTING entspricht TRUNCATE_EXISTING mit der Ausnahme, daß der bestehende Dateiinhalt nicht gelöscht wird. In Verbindung mit der Funktion SetFilePointer kann man dann jedesmal die neuen Informationen an die bestehende Datei anhängen.

Die Konsole, Schnittstelle oder Datei wird beschrieben mit:

push dword 0
push dword Addresse ;einer Variable welche mit der
;Anzahl geschriebener Bytes gefüllt werden soll
push dword AnzahlZuSchreibenderBytes
push dword AddresseDerZuSchreibendenBytes
push dword handle
;erhalten durch GetStdHandle oder CreateFile
call [WriteFile]

Bei Zeichenketten braucht keine abschließende 0 zu folgen, da die Länge der Zeichenkette explizit angegeben wird. Um eine neue Zeile zu starten, muß man einfach die Bytes für den Zeilenrücklauf einfügen.

Sonstige Anmerkungen

Nun sollten die meisten Knackpunkte bekannt sein die man braucht, um Programme in Assembler für Win32 zu schreiben oder auf Win32 zu portieren. Die meisten weitergehenden Themen sind in den SDKs enthalten, sind dort recht gut dokumentiert und oft mit Beispielcode versehen (überwiegend in C, aber dennoch auch für nicht-C-Programmierer verständlich). Damit ist dieses Win32Asm-Tutorial beendet, da die meisten Algorithmen nicht sprachspezifisch sind und somit nicht in dieses Tutorial passen. Es sollte auch kein Problem darstellen dürfen, sich im SDK zurechtzufinden (und nur Sourcecode zu verbreiten bringt auch nichts, man muß es schon selber implementieren können).

Im Gedächtnis behalten sollte man sich aber immer:

* Auch wenn die Win32API die Zugriffe auf Hardware standardisiert bedeutet dies nicht, daß auch die Hardware selbst standardisiert ist. Falls ein Programm spezielle Hardware erfordert, welche nicht auf jedem Rechner vorhanden ist, kann es nie schaden, eine Alternative dazu in den Code mit einzubauen, oder, falls es sich um eine gängige Komponente handelt, zumindest dem Benützer des Programmes mitteilt, woran das Problem liegt. Insbesonder gilt dies bei den Multimedia-APIs: Die Unterschiede in den vorhandenen Bildschirmauflösungen, Pixelformaten, Soundpuffer-Latenzzeiten usw. sind teilweise enorm.

* Andere Programme laufen zur selben Zeit wie das eigene. Aus diesem Grunde schwankt die Verfügbarkeit von Rechenzeit, Speicher und Plattenplatz mehr oder weniger stark auch während das Programm läuft, insbesondere wenn das Programm deaktiviert/minimiert wird. Mittlerweile ist auch die Möglichkeit, das ganze Betriebsystem mit allen Programmen komplett für Stunden oder Tage anzuhalten, verbreitet (man denke nur an den Ruhezustand von Windows ME und Windows 2000).

* Es ist meist sinnvoll zu überprüfen, ob eine API-Funktion auch erfolgreich war. Viele Fehler sind zudem nicht kritisch (z.b., wenn eine DirectDraw Surface verlorengeht, weil das Programm minimiert wurde) und können ohne großen Aufwand behoben oder abgefangen werden.

* Die Beispielprogramme und Headerdateien sind nicht immer korrekt. So enthielt eine ältere Version der win32n.inc für NASM eine falsch definierte Konstante. Beim Vergleichen mit anderen Header- bzw. Includedateien findet man den Fehler aber meist recht schnell.

Viel Spaß beim Coden und haltet nach anderen Texten von mir Ausschau.

TS