Stos wywołań można również nazwać stosem ramek.
Rzeczy, które są układane w stos po zasadzie LIFO, nie są zmiennymi lokalnymi, ale całymi ramkami stosu („wywołaniami”) wywoływanych funkcji . Zmienne lokalne są wypychane i wstawiane razem z tymi klatkami w tak zwanym prologu funkcji i epilogu .
Wewnątrz ramki kolejność zmiennych jest zupełnie nieokreślona; Kompilatory odpowiednio „zmieniają kolejność” pozycji zmiennych lokalnych wewnątrz ramki, aby zoptymalizować ich wyrównanie, tak aby procesor mógł je pobrać tak szybko, jak to możliwe. Kluczowym faktem jest to, że przesunięcie zmiennych względem jakiegoś ustalonego adresu jest stałe przez cały okres życia ramki - więc wystarczy wziąć adres zakotwiczenia, powiedzmy, adres samej ramki i pracować z przesunięciami tego adresu do zmienne. Taki adres zakotwiczenia jest faktycznie zawarty w tak zwanym wskaźniku bazowym lub ramcektóry jest przechowywany w rejestrze EBP. Z drugiej strony przesunięcia są wyraźnie znane w czasie kompilacji i dlatego są na stałe zakodowane w kodzie maszynowym.
Ta grafika z Wikipedii pokazuje strukturę typowego stosu wywołań 1 :
Dodaj przesunięcie zmiennej, do której chcemy uzyskać dostęp, do adresu zawartego we wskaźniku ramki i otrzymamy adres naszej zmiennej. Krótko mówiąc, kod po prostu uzyskuje do nich dostęp bezpośrednio poprzez stałe przesunięcia czasu kompilacji od wskaźnika podstawowego; To prosta arytmetyka wskaźników.
Przykład
#include <iostream>
int main()
{
char c = std::cin.get();
std::cout << c;
}
gcc.godbolt.org daje nam
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
.. dla main
. Kod podzieliłem na trzy podrozdziały. Prolog funkcji składa się z pierwszych trzech operacji:
- Podstawowy wskaźnik jest umieszczany na stosie.
- Wskaźnik stosu jest zapisywany we wskaźniku podstawowym
- Wskaźnik stosu jest odejmowany, aby zrobić miejsce na zmienne lokalne.
Następnie cin
jest przenoszony do rejestru EDI 2 iget
wywoływany; Wartość zwracana jest w EAX.
Na razie w porządku. Teraz dzieje się interesująca rzecz:
Najniższy bajt EAX, oznaczony przez 8-bitowy rejestr AL, jest pobierany i zapisywany w bajcie tuż za wskaźnikiem podstawowym : to znaczy -1(%rbp)
, przesunięcie wskaźnika podstawowego wynosi -1
. Ten bajt jest naszą zmiennąc
. Przesunięcie jest ujemne, ponieważ stos rośnie w dół na x86. Następna operacja jest przechowywana c
w EAX: EAX jest przenoszony do ESI, cout
przenoszony do EDI, a następnie wywoływany jest operator wstawiania z argumentami cout
i c
będący argumentami.
Wreszcie,
- Zwracana wartość
main
jest przechowywana w EAX: 0. Wynika to z niejawnej return
instrukcji. Możesz także zobaczyć xorl rax rax
zamiast movl
.
- wyjdź i wróć do strony wezwania.
leave
skraca ten epilog i niejawnie
- Zastępuje wskaźnik stosu wskaźnikiem podstawowym i
- Zdejmuje wskaźnik bazowy.
Po wykonaniu tej operacji i ret
wykonaniu tej operacji ramka została skutecznie usunięta, chociaż wywołujący nadal musi wyczyścić argumenty, ponieważ używamy konwencji wywoływania cdecl. Inne konwencje, np. Stdcall, wymagają od wywoływanego uporządkowania, np. Poprzez przekazanie ilości bajtów do ret
.
Pominięcie wskaźnika ramki
Można również nie używać przesunięć ze wskaźnika podstawy / ramki, ale zamiast tego ze wskaźnika stosu (ESB). To sprawia, że rejestr EBP, który w przeciwnym razie zawierałby wartość wskaźnika ramki, jest dostępny do dowolnego użytku - ale może uniemożliwić debugowanie na niektórych maszynach i zostanie domyślnie wyłączony dla niektórych funkcji . Jest to szczególnie przydatne podczas kompilacji dla procesorów z tylko kilkoma rejestrami, w tym x86.
Ta optymalizacja jest znana jako FPO (pominięcie wskaźnika ramki) i jest ustawiana przez -fomit-frame-pointer
GCC i -Oy
Clang; zwróć uwagę, że jest on niejawnie wyzwalany przez każdy poziom optymalizacji> 0 wtedy i tylko wtedy, gdy debugowanie jest nadal możliwe, ponieważ nie ma żadnych dodatkowych kosztów. Więcej informacji można znaleźć tutaj i tutaj .
1 Jak wskazano w komentarzach, wskaźnik ramki przypuszczalnie ma wskazywać na adres za adresem zwrotnym.
2 Zauważ, że rejestry zaczynające się od R są 64-bitowymi odpowiednikami tych, które zaczynają się od E. EAX wyznacza cztery bajty RAX o najniższej kolejności. Dla jasności użyłem nazw rejestrów 32-bitowych.