Der Aufrufstapel kann auch als Frame-Stack bezeichnet werden.
Die Dinge, die nach dem LIFO-Prinzip gestapelt werden, sind nicht die lokalen Variablen, sondern die gesamten Stapelrahmen ("Aufrufe") der aufgerufenen Funktionen . Die lokalen Variablen werden zusammen mit diesen Frames im sogenannten Funktionsprolog und Epilog verschoben und gepoppt .
Innerhalb des Rahmens ist die Reihenfolge der Variablen völlig unbestimmt. Compiler "ordnen" die Positionen lokaler Variablen innerhalb eines Frames entsprechend neu an, um ihre Ausrichtung zu optimieren, damit der Prozessor sie so schnell wie möglich abrufen kann. Die entscheidende Tatsache ist, dass der Versatz der Variablen relativ zu einer festen Adresse während der gesamten Lebensdauer des Rahmens konstant ist. Es reicht also aus, eine Ankeradresse, beispielsweise die Adresse des Rahmens selbst, zu verwenden und mit Versätzen dieser Adresse zu arbeiten die Variablen. Eine solche Ankeradresse ist tatsächlich in der sogenannten Basis enthalten, oder Rahmenzeiger enthalten die im EBP-Register gespeichert ist. Die Offsets hingegen sind zum Zeitpunkt der Kompilierung klar bekannt und daher im Maschinencode fest codiert.
Diese Grafik aus Wikipedia zeigt, wie der typische Aufrufstapel wie folgt aufgebaut ist : 1 :
Fügen Sie den Offset einer Variablen, auf die wir zugreifen möchten, zu der im Frame-Zeiger enthaltenen Adresse hinzu, und wir erhalten die Adresse unserer Variablen. Kurz gesagt, der Code greift direkt über konstante Kompilierungszeit-Offsets vom Basiszeiger auf sie zu. Es ist eine einfache Zeigerarithmetik.
Beispiel
#include <iostream>
int main()
{
char c = std::cin.get();
std::cout << c;
}
gcc.godbolt.org gibt uns
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl std::cin, %edi
call std::basic_istream<char, std::char_traits<char> >::get()
movb %al, -1(%rbp)
movsbl -1(%rbp), %eax
movl %eax, %esi
movl std::cout, %edi
call [... the insertion operator for char, long thing... ]
movl $0, %eax
leave
ret
.. für main
. Ich habe den Code in drei Unterabschnitte unterteilt. Der Funktionsprolog besteht aus den ersten drei Operationen:
- Der Basiszeiger wird auf den Stapel geschoben.
- Der Stapelzeiger wird im Basiszeiger gespeichert
- Der Stapelzeiger wird subtrahiert, um Platz für lokale Variablen zu schaffen.
Dann cin
wird in das EDI-Register 2 verschoben und get
aufgerufen; Der Rückgabewert ist in EAX.
So weit, ist es gut. Jetzt passiert das Interessante:
Das durch das 8-Bit-Register AL bezeichnete niederwertige Byte von EAX wird direkt nach dem Basiszeiger genommen und im Byte gespeichert : Das heißt -1(%rbp)
, der Versatz des Basiszeigers ist -1
. Dieses Byte ist unsere Variablec
. Der Versatz ist negativ, da der Stapel auf x86 nach unten wächst. Die nächste Operation wird c
in EAX gespeichert: EAX wird in ESI verschoben, cout
wird in EDI verschoben und dann wird der Einfügeoperator mit cout
und c
als Argument aufgerufen .
Schließlich,
- Der Rückgabewert von
main
wird in EAX: 0 gespeichert. Dies liegt an der impliziten return
Anweisung. Sie könnten auch xorl rax rax
anstelle von sehen movl
.
- verlassen und zur Anrufstelle zurückkehren.
leave
verkürzt diesen Epilog und implizit
- Ersetzt den Stapelzeiger durch den Basiszeiger und
- Öffnet den Basiszeiger.
Nach dieser Operation und nachdem ret
sie ausgeführt wurde, wurde der Frame effektiv gelöscht, obwohl der Aufrufer die Argumente noch bereinigen muss, da wir die Aufrufkonvention cdecl verwenden. Andere Konventionen, z. B. stdcall, erfordern, dass der Angerufene aufräumt, z. B. indem er die Anzahl der Bytes an übergibt ret
.
Auslassung des Rahmenzeigers
Es ist auch möglich, keine Offsets vom Basis- / Frame-Zeiger, sondern vom Stack-Zeiger (ESB) zu verwenden. Dies macht das EBP-Register, das sonst den Frame-Zeigerwert enthalten würde, für eine willkürliche Verwendung verfügbar - es kann jedoch das Debuggen auf einigen Computern unmöglich machen und wird für einige Funktionen implizit deaktiviert . Dies ist besonders nützlich, wenn Sie für Prozessoren mit nur wenigen Registern kompilieren, einschließlich x86.
Diese Optimierung wird als FPO (Frame Pointer Ommission) bezeichnet und von -fomit-frame-pointer
in GCC und -Oy
in Clang festgelegt. Beachten Sie, dass es implizit von jeder Optimierungsstufe> 0 ausgelöst wird, wenn und nur wenn das Debuggen noch möglich ist, da es sonst keine Kosten verursacht. Weitere Informationen finden Sie hier und hier .
1 Wie in den Kommentaren ausgeführt, soll der Rahmenzeiger vermutlich auf die Adresse nach der Rücksprungadresse zeigen.
2 Beachten Sie, dass die Register, die mit R beginnen, die 64-Bit-Gegenstücke derjenigen sind, die mit E beginnen. EAX bezeichnet die vier niederwertigen Bytes von RAX. Ich habe die Namen der 32-Bit-Register zur Klarheit verwendet.