La pile d'appels peut également être appelée pile de trames.
Les choses qui sont empilés après le principe LIFO ne sont pas les variables locales mais les cadres de pile entiers ("appels") des fonctions appelées . Les variables locales sont poussées et retirées en même temps que les cadres dans ce que l'on appelle les prologue de la fonction y épilogue respectivement.
À l'intérieur du cadre, l'ordre des variables n'est absolument pas spécifié ; Compilateurs "réordonner" les positions des variables locales à l'intérieur d'un cadre 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 pendant toute la durée de vie de la trame. - Il suffit donc de prendre une adresse d'ancrage, disons l'adresse de la trame elle-même, et de travailler avec des décalages de cette adresse vers les variables. Une telle adresse d'ancrage est en fait contenue dans le fichier appelé base o pointeur de cadre qui 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 la structure typique d'une pile d'appels 1 :
En ajoutant l'offset de la variable à laquelle nous voulons accéder à l'adresse contenue dans le pointeur de trame, nous obtenons l'adresse de notre variable. En bref, le code y accède directement par le biais de décalages constants au moment de la compilation à partir du pointeur de base ; il s'agit d'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 est constitué des trois premières opérations :
- Le pointeur de base est poussé sur la pile.
- Le pointeur de pile est sauvegardé dans le pointeur de base.
- Le pointeur de pile est soustrait pour faire de la place aux variables locales.
Puis cin
est déplacé dans le registre EDI 2 y get
est appelé ; la valeur de retour est dans EAX.
Jusqu'à présent, tout va bien. Maintenant, la chose intéressante arrive :
L'octet de poids faible de 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)
le décalage du pointeur de base est -1
. Cet octet est notre variable c
. Le décalage est négatif car la pile croît vers le bas sur x86. L'opération suivante stocke c
dans EAX : EAX est déplacé vers ESI, cout
est déplacé vers l'EDI et ensuite l'opérateur d'insertion est appelé avec cout
y c
étant les arguments.
Enfin,
- La valeur de retour de
main
est stocké dans EAX : 0. C'est à cause de l'implicite return
déclaration. Vous pouvez également voir xorl rax rax
au lieu de movl
.
- quitter et revenir sur le lieu de l'appel.
leave
abrège cet épilogue et implicitement
- Remplace le pointeur de pile par le pointeur de base et
- Fait sauter le pointeur de base.
Après cette opération et ret
ont été effectuées, le cadre a effectivement été retiré, bien que l'appelant doive encore nettoyer les arguments puisque nous utilisons la convention d'appel cdecl. D'autres conventions, comme stdcall, demandent à l'appelant de faire le ménage, par exemple en passant la quantité d'octets à ret
.
Omission de pointeur de trame
Il est également possible de ne pas utiliser les décalages du pointeur de base/trame mais du pointeur de pile (ESB). Cela rend le registre EBP, qui contiendrait autrement la valeur du pointeur de trame, disponible pour une utilisation arbitraire - mais cela peut rendre l'utilisation de l'ESB plus difficile. débogage impossible sur certaines machines et sera implicitement désactivé pour certaines fonctions . Elle est particulièrement utile lors de la compilation pour les processeurs ne possédant que peu de registres, y compris les x86.
Cette optimisation est connue sous le nom de FPO (frame pointer omission) et définie par -fomit-frame-pointer
dans le CCG 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 encore possible, puisqu'il n'a aucun coût à part cela. Pour plus d'informations, voir aquí y aquí .
1 Comme indiqué dans les commentaires, le pointeur de trame est vraisemblablement censé pointer vers l'adresse après l'adresse de retour.
2 Notez que les registres qui commencent par R sont les équivalents 64 bits de ceux qui commencent 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é.
15 votes
LIFO n'a d'importance que pour réserver de l'espace sur la pile. Vous pouvez toujours accéder à n'importe quelle variable qui se trouve au moins sur votre cadre de pile (déclarée à l'intérieur de la fonction), même si elle se trouve sous un grand nombre d'autres variables.
3 votes
En d'autres termes,
LIFO
signifie que vous pouvez ajouter ou supprimer des éléments uniquement à la fin de la pile, et que vous pouvez toujours lire/modifier n'importe quel élément.15 votes
Pourquoi ne pas désassembler une fonction simple après avoir compilé avec -O0 et regarder les instructions générées ? C'est assez, eh bien, instructif ;-). Vous constaterez que le code fait bon usage de la partie R de la RAM ; il accède directement aux adresses à volonté. Vous pouvez considérer le nom d'une variable comme un décalage vers un registre d'adresses (le pointeur de pile). Comme les autres l'ont dit, la pile est juste LIFO en ce qui concerne l'empilage (bon pour la récursion, etc.). Ce n'est pas LIFO en ce qui concerne l'accès à la pile. L'accès est complètement aléatoire.
7 votes
Vous pouvez créer votre propre structure de données de pile en utilisant un tableau, et en stockant simplement l'index de l'élément supérieur, en l'incrémentant lorsque vous poussez, et en le décrémentant lorsque vous poussez. Si vous faites cela, vous serez toujours en mesure d'accéder à n'importe quel élément individuel du tableau à tout moment sans le pousser ou le faire sauter, comme vous le faites toujours avec les tableaux. C'est à peu près la même chose qui se passe ici.
0 votes
@PaulGriffiths ; Bonne explication.
0 votes
Merci à tous pour ces excellents commentaires. Vous m'avez aidé à mieux comprendre des choses de si bas niveau. Vous êtes géniaux !
1 votes
Autre remarque : en C au moins, le compilateur n'est pas du tout obligé de placer les variables sur la pile. L'optimiseur peut les garder dans les registres. Il peut aussi les placer sur la pile dans un ordre différent. Jetez également un coup d'œil au langage "Forth" et à sa programmation orientée pile.
1 votes
Un cadre de pile unique est une zone de mémoire essentiellement non structurée, sous le contrôle exclusif du compilateur. Il n'y a pas de règles que le compilateur doit respecter à l'intérieur d'un cadre unique. Il peut violer l'"ordre LIFO" et gribouiller légalement des octets comme bon lui semble. La structure de la pile provient principalement de l'exigence selon laquelle de nombreux langages différents doivent interopérer et s'appeler les uns les autres (en utilisant une convention d'appel commune).
1 votes
Pour donner une perspective historique : lorsque la pile a été inventée, elle n'était en effet utilisée que de manière LIFO ; vous poussiez les données sur la pile pour les sauvegarder pendant que vous faisiez autre chose, et vous les retiriez lorsque vous aviez terminé. Donc, à cette époque, comment utilisiez-vous la pile pour stocker des variables locales ? En général, on ne le faisait pas ! Les variables locales vivaient dans la mémoire statique, et oui, cela signifiait que la plupart des procédures n'étaient pas ré-entrantes. Et puis les stack frames ont été inventées, et c'était la liesse générale :-)
1 votes
La norme C ne mentionne même pas le mot "pile". Une pile contiguë comme celle dont il est question ici est certainement la façon la plus courante d'implémenter le modèle de mémoire du C, mais ce n'est pas la seule. Il existe des compilateurs C qui allouent la mémoire pour les appels de fonction à partir du tas, ou quelque chose de similaire, de sorte que l'ordre en mémoire des cadres pour les appels successifs est entièrement arbitraire.
0 votes
On n'empile pas les variables, mais cadres :)
0 votes
Notez également que, dans votre exemple, certaines conventions d'appel n'utiliseront même pas la pile d'appel pour stocker ou transmettre ces valeurs, mais simplement les registres.
0 votes
En général, un pointeur de pile commence à l'adresse la plus élevée de la pile et croît vers des adresses plus basses au fur et à mesure que des éléments sont poussés sur la pile. Les éléments sont placés sur la pile dans l'ordre dans lequel ils sont rencontrés, de sorte que l'entrée supérieure de la pile sera '1', l'entrée suivante sur la pile (à une adresse inférieure) sera '2' et ainsi de suite.
3 votes
Fondamentalement, la dénomination de stack/heap est malheureuse. Ils ont peu de ressemblance avec la pile et le tas dans la terminologie des structures de données, donc les appeler de la même façon est très déroutant.
1 votes
@PeterMortensen Eh bien, il a été écrit de manière à dessiner un scénario spécifique. Comme je l'ai dit, j'ai lu à peu près tout ce que j'ai trouvé sur le sujet, mais je n'ai pas trouvé de réponse. exactement ces morceaux manquants. Les gens ont donné de bonnes réponses ici, donc je pense que tout le monde devrait être content :)
1 votes
IMO la meilleure façon de comprendre correctement la pile et les conventions d'appel est d'écrire un peu d'assemblage.
0 votes
@Christoph May , je sais ce que vous vouliez dire par "Mais encore une fois, cela signifie qu'il n'y a pas de pop up de la pile jusqu'à ce que nous arrivions à a et b". Je ne comprends pas bien.
0 votes
A partir d'une réponse en lien seulement : cet article de blog ( cryptroix.com/2016/10/16/journey-to-the-stack ) est assez bon. Il utilise Python comme pseudo-code pour les fonctions asm simples (comme vous pourriez trouver dans la sortie du compilateur C), et suppose une convention d'appel stack-args (les conventions d'appel modernes passent les args dans les registres).