Stack-Frame Unklarheiten

Disclaimer: Dieser Thread wurde aus dem alten Forum importiert. Daher werden eventuell nicht alle Formatierungen richtig angezeigt. Der ursprüngliche Thread beginnt im zweiten Post dieses Threads.

Stack-Frame Unklarheiten
Hallo zusammen,

ich hätte mal eine grundsätzliche Frage zu einem Thema, welches mir schon seit Beginn des Semesters nicht ganz klar geworden ist, nämlich dem Aufruf von Methoden in Assembler und dem damit verbundenen Aufbau des Stacks. :slight_smile:
Die WS 2012 Klausur http://www3.informatik.uni-erlangen.de/Lehre/GRa/Klausuren/klausur-gra-2013-04-11.pdf betrachtet ja in Aufgabe 1 eben den Aufbau eines Stacks.
Anscheinend dürfte es reichen, nur den jeweiligen EIP zu sichern und ggf. die Werte, welche explizit mit push $0x42 auf den Stack gelegt werden. Das ergibt auch soweit Sinn, da so eben die call-Methoden die Rücksprungadressen auf den Stack speichern, welche von ret wieder heruntergenommen und an die CPU weitergegeben werden.

In dem Foliensatz 2 F. 53-56 wurden jedoch wesentlich mehr Details zu Untermethodenaufrufen angesprochen. Neben der Existenz von caller-save und callee-save Registern ist es wichtig zu wissen, wie ein Stack-Frame aussieht. Die Folien vermitteln auch den Eindruck, dass immer beim Aufruf von call ein Stack-Frame aufgebaut werden müsse, was aber nach Bearbeitung der Nr. 1 in der WS 12 Klausur nicht so ist. Kann es sein, dass man gar nicht die Parameter, den EIP, den alten Stackpointer bzw. ebp und dann die lokalen Variablen auf dem Stack sichern muss? Ist ausschließlich das Sichern des EIP essentiell ist und alle anderen Dinge können weggelassen werden? Wann baut man dann zwingend/ überhaupt den vollständigen Stackframe inkl. Speicherung des EBP und der übergebenen Parameter auf? In der Klausuraufgabe wurde der Parameter hinter call in Zeile 21 ja nicht auf den Stack gelegt.

Vielen Dank!
froschigon

Attachment:
Bildschirmfoto 2016-09-05 um 19.21.26.png:
https://fsi.cs.fau.de/unb-attachments/post_147915/Bildschirmfoto 2016-09-05 um 19.21.26.png


Wenn ich die Klausuraufgabe richtig lese, dann ist „“ kein Parameter, sondern „[m]call 0 [/m]“ bedeutet soviel wie „Rufe die Funktion an Adresse 0 auf, das ist übrigens die Funktion a“.

Wenn ich den gesamten Code Lese, könnte Funktion a z.B. aus folgendem C99-Code entstanden sein:

void a(void) {
    b();
    int foo = 0x42;
    c();    
}

Da die Funktion a keine Parameter hat, müssen in „main“ vor „call a“ auch keine auf den Stack gelegt werden. Da in der Funktion a keine callee-saved Register benutzt werden, müssen im Prolog von a auch keine gesichert werden. Da in Funktion a keine caller-saved Register benutzt werden müssen in Funktion a direkt vor den Calls zu b und c auch keine Register gesichert werden. Nach dem „push $0x42“ zeigt der ESP nicht mehr auf die Return-Adresse, deswegen muss der ESP vor dem „ret“ korrigiert werden.

Die Verwendung von EBP ist grundsätzlich optional. Wäre EBP verwendet worden, sähe der Code von Funktion a statt so:
[m]call 10
push $0x42
call 11
add $0x4 ,%esp
ret[/m]

z.B. wie folgt aus:
[m]push %ebp
mov %esp, %ebp
call 10
push $0x42
call 11
mov %ebp, %esp
pop %ebp
ret[/m]

Warum könnte man das wollen? Zum einen muss man sich hier nicht merken, wie oft push gemacht wurde, man hat sich ja den alten ESP zu Begin in EBP gesichert, und muss diesen Wert lediglich zurückkopieren. Andererseits muss man jetzt den alten Wert von EBP sichern.

Zusätzlich vereinfacht es das Debuggen:

Ohne EBP:
Ich halte ein laufendes Programm an. Mit Hilfe von EIP weiß ich wo im Programmcode ich bin, ich kann rekonstruieren wieviel Pushes oder andere Stack-Operationen seit Funktionsbegin passiert sind und so ausrechnen wieviel ich von ESP abziehen muss um die Rücksprungadresse, also den alten EIP, zu finden. So kann ich herausfinden aus welcher Funktion die aktuelle aufgerufen wurde… bzw. von genau welcher Stelle aus im Code… hier kann ich wieder ausrechnen wieviel noch weiter ich auf dem Steck gehen muss um die nächste Rücksprungadresse zu finden, usw.

Mit EBP:
Ich halte ein laufendes Programm an. Mit Hilfe von EIP weiß ich wo im Programmcode ich bin, ohne Push-, Pop- und andere ESP-Operationen zu zählen weiß ich sofort, dass die Rücksprungaddresse bei [m]eip1 = Speicher(EBP+4)[/m] steht und dass der „EBP innerhalb der aufrufenden Funktion“ an Adresse [m]ebp1 = Speicher(EBP)[/m] steht. Von dort aus kann ich mich weiterhangeln mit [m]eip2 = Speicher(ebp1 + 4)[/m] und [m]ebp2 = Speicher(ebp1)[/m], usw.


Vielen Dank für die ausführliche Antwort, welche sehr hilfreich war!

Mit sind noch zwei Sachverhalte nicht ganz klar geworden:

Wie sähe es aus, wenn nun die Funktion a Parameter hätte (wie in dem Beispiel unten)?
void a(int param1, int param2) {
int localParam1 = param1;
int localParam2 = param2;
b();
int foo = 0x42;
c();
}

Ich habe im Folgenden versucht, den Stack eimal unter Verwendung von EBP und einmal ohne zu skizzieren:

Mit EBP - Aufruf von call a param1 param2

localParam2 (ESP zeigt hierauf)
localParam1
17
EBP (= Alter ESP)
param1
param2
(Steckanfang)

Aufruf von call b im Anschluss daran:

Return address: 5 (ESP zeigt hierauf)
EBP
localParam2
localParam1
Return address: 17
EBP
param1
param2
(Steckanfang)

Ohne EBP - Aufruf von call a param1 param2

localParam2
localParam1
17 (ESP zeigt hierauf)
param1
param2
(Steckanfang)

Aufruf von call b im Anschluss daran:

5 (ESP zeigt hierauf)
localParam2
localParam1
17
param1
param2
(Steckanfang)

Habe ich das richtig verstanden? In den Folien 2 Seite 41 wird nämlich zuerst das Ergebnis und dann der Parameter auf dem Stack gesichert (falls das überhaupt relevant ist).

Meine zweite Frage wäre folgende:

Ist es korrekt zu sagen, dass man den ESP immer auf die Return-Adresse zeigen lassen muss, wenn man keinen EBP verwendet? Wenn man einen verwendet, dann zeigt der ESP wie gehabt wirklich auf das oberste Element im Stack? Also z.B. auch auf $0x42?

Vielen Dank nochmals!
froschigon


Noch ein kurzer Nachtrag zum Thema Frame-Zeiger: Ein Frame-Zeiger ist nur in genau einer Situation unbedingt notwendig - und zwar dann, wenn die Größe des Stack-Frames nicht zur Übersetzungszeit bekannt sein kann.

Das ist zum Beispiel dann der Fall, wenn man ein Array dynamischer Größe auf dem Stack anlegt:

void foo(int n) {
    int array[n]; // n ist zur Uebersetzungszeit nicht bekannt
    ...
}

Oder beim dynamischen Allozieren von Speicher auf dem Stack mittels [m]alloca()[/m]:

void foo(int n) {
    int *array = alloca(n * sizeof(*array)); // n ist zur Uebersetzungszeit nicht bekannt
    ...
}

(Der Aufruf der Pseudo-Funktion [m]alloca()[/m] wird durch den Compiler in ein schlichtes Verschieben des Stack-Zeigers übersetzt.)

In dieser Situation kann man nicht mehr durch bloßes Addieren einer Konstante vom aktuellen Stack-Zeiger auf den Beginn des Stack-Frames zurückrechnen. Deswegen muss man sich den Beginn des Frames in einem extra Register merken - dem Frame-Zeiger-Register, auf x86 [m]%ebp[/m].

Braucht man keinen Frame-Zeiger, steht dieses dedizierte Register innerhalb der Funktion zur freien Verfügung und kann beliebig verwendet werden.


Vielen Dank für die echt interessanten Informationen. :slight_smile: Meine Fragen von 11:46 Uhr heute habe ich leider in der Zwischenzeit noch nicht klären können. Könnt ihr mal kurz schauen, ob ich es richtig verstanden habe?


Ich bin gewohnt, den Stack anders herum zu malen, hohe Adressen oben, nach unten wachsend. Aber im Prinzip ist das Geschmackssache.

Ein paar Anmerkungen jedoch:

(Reihenfolge Return-Adresse, gesicherter EBP)
Wie in meinem Assembler-Schnipsel zu sehen, wird der EBP vom Callee, also der aufgerufenen Funktion gesichert und neu gesetzt. EBP ist ein Callee-Saved Register.

(alter EBP vs. alter ESP)
„push %ebp“ sichert den alten EBP auf den Stack (das war vermutlich auch mal ein ESP, theoretisch könnte die vorherige Funktion statt eines Frame-Pointers auch irgendetwas anderes in EBP speichern und es funktioniert immernoch - wir sichern EBP, verwenden EBP selbst als Framepointer und stellen vor dem „ret“ den alten EBP wieder her).
„mov %esp, %ebp“: Unser Stackframe für die aktuelle Funktion beginnt jetzt. %ebp wird sich im Gegensatz zu %esp innerhalb unserer Funktion nicht mehr ändern. Wird %ebp als Stackframe-Pointer verwendet, zeigt es immer auf den gespeicherten alten %ebp, die praktische Verkettung für’s Debuggen.

(localParam1, localParam2)
Diese können, müssen aber nicht auf dem Stack abgelegt werden, das ist Entscheidung des Compilers.

[m]localParam2 (ESP zeigt hierauf)
localParam1
alterEBP (EBP zeigt hierauf)
17 (Return-Adresse)
param1
param2
(Steckanfang)[/m]

Falls b() so aussieht, also a() und b() %ebp als Frame-Pointer benutzen:
[m]
b:
push %ebp
mov %esp, %ebp
mov %ebp, %esp
pop %ebp
ret
[/m]

Sähe der Stack zwischen den beiden mov-Befehlen in b() so aus:

[m]
alterEBP (EBP zeigt hierauf) (ESP zeigt hierauf)
5 (Return-Adresse)
localParam2
localParam1
alterEBP
17
param1
param2
(Steckanfang)[/m]

Würde nur a() %ebp benutzen und b() wie bisher aussehen, sähe der Stack vor dem „ret“ in b() so aus:

[m]
5 (Return-Adresse) (ESP zeigt hierauf)
localParam2
localParam1
alterEBP (EBP zeigt hierauf)
17
param1
param2
(Steckanfang)[/m]

(Bzw. auch in meiner Version von b sähe direkt vor dem ret der Stack so aus).

„ret“ ist im Prinzip nichts anderes als „pop %eip“, also nehme einen Wert vom Stack (inkl. Anpassung von %esp) und nehme dies als neuen Instruction Pointer. D.h. direkt vor dem „ret“ muss %esp immer auf die Return-Adresse zeigen, unabhängig von EBP. Mit Hilfe von EBP wird aus der Korrektur von ESP statt einer Berechnung ein einfaches zurückkopieren.