Funktionen
Aufbau von Funktionen im Maschinencode.
Jeder Code in einem C/C++-Programm befindet sich in irgendeiner Funktion. Und diese Funktion wird von einer anderen Funktion aufgerufen. Daher sollte man den Aufbau einer Funktion im Assembler-Code verstanden haben.
Das ist auch deshalb wichtig, da alle Zugriffe auf irgendwelche Informationen/Speicher über Variablen durchgeführt werden. und die wichtigsten Variablentypen einen direkten Bezug zu einer Funktion:
- (Funktions-) Parameter.
- lokale Variablen (lokal zur aktuellen Funktion),
- Member-Variablen (der this-Zeiger wird beim Aufruf der Member-Funktion gesetzt)
- ...
Der Zugriff auf den scheinbar funktionsunabhängigen Heap-Speicher geschieht über Pointer-Variablen, die dann wieder funktionsbezogen definiert werden. s.o.
Die einzige Ausnahme sind statische bzw. globale Variablen, die jedoch (hoffentlich) seltener im modernen Programmcode verwendet werden und einfacher im Maschinencode zu lesen sind.
Grundgerüst
Im Assembler-Code hat jede Funktion einen "Function Prologue" und "Function Epilogue". Dazwischen befindet sich der von Programmierer geschriebene Code.
Hier wird zuerst die leicht lesbare Version beschrieben, ohne Optimierung (Debug-Mode) beschrieben. Im Anschluss folgen die Unterschiede zur optimieren Version (Release-Mode).
Für jede Funktion gibt es einen Stack-Frame Base Pointer (Base-Pointer), der in EBP gespeichert ist. Basieren auf diesem Base-Pointer passieren alle Funktionsrelevanten Zugriffe (Parameter, lokale Variablen, Rücksprungadresse)
Prologue
Eine Funktion beginnt üblicherweise mit einem Vorspann. Dabei wird der Stack-Frame aufgebaut, ggf. Vorbereitungen für Exceptionshandler und Security-Checks aufgebaut, Platz für lokale Variablen geschaffen und Register gesichert.
push ebp ; sichern des Base-Pointers mov ebp, esp ; Der aktuelle Stack-Pointer ist der neue Base-Pointer ; ebp zeigt auf den alten ebp ; ggf. Vorbereitungen für Exceptionshandler und Security-Checks. push -1 push _ _ ehhandler$?{function_name} mov eax, DWORD PTR fs:0 ; Platz für lokale Variablen schaffen: sub esp, 14h ; 20 Byte z.B. 5 int Variablen ; oder alternativ: push ecx ; bei 4 Byte großen lokalen Variablen ; Register sichern push ebx push esi push edi
Aufbau des Stack-Frames
push ebp ; sichern des Base-Pointers mov ebp, esp ; Der aktuelle Stack-Pointer ist der neue Base-Pointer
Es wird es der Base-Pointer der Aufrufenden Funktion gesichert und anschließend der Wert der Stack-Pointers als neuem Base-Pointer verwendet.
Register | Beschreibung |
---|---|
ESP | Aktueller Stack-Pointer. Wird durch die Kommandos ''push'' und ''pop'' geändert. |
EBP | Base-Pointer. Konstant innerhalb einer Funktion. |
Nach dem Funktionsaufruf, zeigt EBP, der Base-Pointer immer noch auf den Frame der aufrufenden Funktion. Daher wird in der ersten Zeile einer Funktion EBP gesichert, so dass am Ende der aktuellen Funktion der Stack-Frame der aufrufenden Funktion restauriert werden kann.
Der aktuelle Wert des Stack-Pointers wird als neuer Base-Pointer verwendet.
Epilogue
Am Ende einer Funktion wird der Rückgabewert gesetzt, der Stack und die anderen Register restauriert. Die Reihenfolge ist umgekehrt wie im Prolog.
Für den Rückgabewert einer Funktion wird in der Intel Welt das Register EAX verwendet.
Unabhängig vom den Aufrufkonversionen ( _ cdecl, _ stdcall) muss der Stack-Pointer und der Base-Pointer wieder hergestellt werden.
Abhängig von den Aufrufkonversionen müssen die Parameter vom Stack abgeräumt werden.
; gesicherte Register wieder restaurieren (s.o.) pop edi pop esi pop ebx mov eax, ecx ; Funktionsrückgabe setzen. mov esp, ebp ; Funktionsende pop ebp ret [x] ; Rücksprung und ggf. aufräumen des Stacks. ; bei _ _ stdcall die Funktion abräumen: ; x = Größe der Parameter
Parameter und lokale Variablen
Der Zugriff auf Funktionsparameter und lokale Variable geschieht über den Stack. Dabei wird relativ zu den Register EBP und ESP auf den Stack zugegriffen.
... dword ptr [ebp - 8] ; Zugriff auf eine 32-Bit große lokale Variablen
Dabei gelten folgende Regeln:
- Vor dem Aufruf packt die aufrufende Funktion die Parameter auf den Stack.
- Der Aktuelle Stack-Pointer beim Aufruf wird als Base-Pointer für die Funktion verwendet.
- Anschließend wird Platz für die lokalen Variablen erzeugt.
Beim einem 32-Bit Programm:
- esp : aktueller Stack-Pointer
- ebp : Base- oder Frame-Pointer
- ebp + 0: gespeicherter Base-Pointer die aufrufenden Funktion
- ebp + 4: Rücksprungadresse (4 = sizeof(pointer))
- ebp + 8: erster Parameter
- ebp - 4: erste lokale Variable bzw.
- ebp - 4: this-Zeiger.
Die Regel, dass auf lokale Variablen mit ebp - n
und auf Parameter mit ebp + n
zugegriffen wird, kann man sich einfach herleiten, wenn man sich bewusst macht:
- dass der Heap von unten nach oben steigt und der Stack von oben dem Heap entgegen wächst und
-
dass erst die Parameter auf den Stack kommen, dann BP gesetzt wird und erst dann die lokalen Variablen erzeugt werden.
:::asm ... dword ptr [ebp + 8] ; Zugriff auf Parameter ... dword ptr [ebp - 8] ; Zugriff auf lokale Variable
Beispiel:
C++-Funktion:
Funktion f(int i, int j, int k)
int f(int i, int j, int k) { int n = i+j+k; return n; }
Result
f: push ebp ; Base-Pointer sichern mov ebp,esp ; neue Base-Pointer setzen. ; Platz für eine 32-Bit Variablen schaffen push ecx ; (Debug) ; sub esp, 4 ; (Release) ; i + j + k mov eax,dword ptr [ebp+8] add eax,dword ptr [ebp+0Ch] add eax,dword ptr [ebp+10h] ; n = Ergebnis aus i + j + k mov dword ptr [ebp-4],eax ; Rückgabewert setzen mov eax,dword ptr [ebp-4] ; Base-Pointer restaurieren mov esp,ebp pop ebp ret
Funktionsaufruf:
C++:
int a = f(1,2,3)
asm:
push 3 push 2 push 1 call f ; Stack-Pointer restaurieren: add esp,0Ch ; 12 = 3x4 Byte ; nur bei _ _ cdecl ; Rückgabewert in die Variable a schreiben: mov dword ptr[ebp-4], eax ; statt dword ptr[ebp-4] kann der Debugger auch dword ptr[a] anzeigen.
Optimierter Code
In einem Release Build kann der Compiler den Stack-Frame weg-optimieren.
Wenn z.B. innerhalb einer (Member-) Funktion keine Stack-Arithmetik benötigt wird, dann kann der Stack-Pointer als Base-Pointer verwendet werden. In diesem Fall spart der Compiler im Funktion Prologue und Epilogue mindestens je 2 Anweisungen ein. (Sichern und restaurieren des Base-Pointer und setzen des Base-Pointers basierend des Stack-Pointers.) Die Zugriffe auf lokale Variablen und Parameter geschieht über den Stack-Pointer (esp).
Dieser Code kann auf den ersten Blick unvollständig erscheinen, wenn man vorher sich am im Debug Mode generierten Prologue und Epilogue Code orientiert hat.
Der Base-Pointer (ebp) zeigt innerhalb dieser Funktion auf den Frame einer übergeordneten Funktion.