La pile d'appels pourrait également être appelée une pile de cadres.
Les choses qui sont empilées après le principe LIFO ne sont pas les variables locales mais l'ensemble des cadres de pile ("appels") des fonctions appelées . Les variables locales sont poussées et sautées avec ces cadres respectivement dans le prologue et l' épilogue de fonction .
À l'intérieur du cadre, l'ordre des variables est totalement indéterminé; Les compilateurs "réorganisent" les positions des variables locales à l'intérieur d'une trame de manière appropriée pour optimiser leur alignement afin que le processeur puisse les récupérer le plus rapidement possible. Le fait crucial est que le décalage des variables par rapport à une adresse fixe est constant tout au long de la durée de vie de la trame - il suffit donc de prendre une adresse d'ancrage, par exemple l'adresse de la trame elle-même, et de travailler avec des décalages de cette adresse pour les variables. Une telle adresse d'ancrage est en fait contenue dans le soi-disant pointeur de base ou de tramequi est stocké dans le registre EBP. Les offsets, par contre, sont clairement connus au moment de la compilation et sont donc codés en dur dans le code machine.
Ce graphique de Wikipedia montre ce que la pile d'appels typique est structurée comme 1 :
Ajoutez le décalage d'une variable à laquelle nous voulons accéder à l'adresse contenue dans le pointeur de trame et nous obtenons l'adresse de notre variable. Donc, brièvement dit, le code y accède directement via des décalages constants au moment de la compilation à partir du pointeur de base; C'est une simple arithmétique de pointeur.
Exemple
#include <iostream>
int main()
{
char c = std::cin.get();
std::cout << c;
}
gcc.godbolt.org nous donne
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
.. pour main
. J'ai divisé le code en trois sous-sections. Le prologue de la fonction comprend les trois premières opérations:
- Le pointeur de base est poussé sur la pile.
- Le pointeur de pile est enregistré dans le pointeur de base
- Le pointeur de pile est soustrait pour faire de la place pour les variables locales.
Puis cin
est déplacé dans le registre EDI 2 et get
est appelé; La valeur de retour est dans EAX.
Jusqu'ici tout va bien. Maintenant, la chose intéressante se produit:
L'octet de poids faible d'EAX, désigné par le registre à 8 bits AL, est pris et stocké dans l'octet juste après le pointeur de base : c'est-à- -1(%rbp)
dire que le décalage du pointeur de base est -1
. Cet octet est notre variablec
. Le décalage est négatif car la pile se développe vers le bas sur x86. L'opération suivante stocke c
dans EAX: EAX est déplacé vers ESI, cout
est déplacé vers EDI, puis l'opérateur d'insertion est appelé avec cout
et c
étant les arguments.
Finalement,
- La valeur de retour de
main
est stockée dans EAX: 0. C'est à cause de l' return
instruction implicite . Vous pourriez également voir à la xorl rax rax
place de movl
.
- quitter et revenir au site d'appel.
leave
abrége cet épilogue et implicitement
- Remplace le pointeur de pile par le pointeur de base et
- Fait apparaître le pointeur de base.
Une fois cette opération ret
effectuée, la trame a effectivement été sautée, bien que l'appelant doive toujours nettoyer les arguments car nous utilisons la convention d'appel cdecl. D'autres conventions, par exemple stdcall, obligent l'appelé à ranger, par exemple en passant le nombre d'octets à ret
.
Omission du pointeur de trame
Il est également possible de ne pas utiliser les décalages à partir du pointeur de base / cadre mais à partir du pointeur de pile (ESB) à la place. Cela rend le registre EBP qui contiendrait autrement la valeur du pointeur de trame disponible pour une utilisation arbitraire - mais cela peut rendre le débogage impossible sur certaines machines , et sera implicitement désactivé pour certaines fonctions . Il est particulièrement utile lors de la compilation pour des processeurs avec seulement quelques registres, y compris x86.
Cette optimisation est connue sous le nom de FPO (omission du pointeur de trame) et définie par -fomit-frame-pointer
dans GCC et -Oy
dans Clang; notez qu'il est implicitement déclenché par chaque niveau d'optimisation> 0 si et seulement si le débogage est toujours possible, car il n'a aucun coût en dehors de cela. Pour plus d'informations, cliquez ici et ici .
1 Comme indiqué dans les commentaires, le pointeur de trame est vraisemblablement destiné à pointer vers l'adresse après l'adresse de retour.
2 Notez que les registres commençant par R sont les équivalents 64 bits de ceux commençant par E. EAX désigne les quatre octets de poids faible de RAX. J'ai utilisé les noms des registres 32 bits pour plus de clarté.