web-dev-qa-db-fra.com

Comment fonctionne exactement la pile d'appels?

J'essaie de mieux comprendre comment fonctionnent les opérations de bas niveau des langages de programmation et surtout comment ils interagissent avec le système d'exploitation/CPU. J'ai probablement lu toutes les réponses dans chaque thread lié à la pile/tas ici sur Stack Overflow, et elles sont toutes géniales. Mais il y a encore une chose que je ne comprenais pas encore complètement.

Considérez cette fonction dans un pseudo code qui a tendance à être valide Rust code ;-)

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(a, b);
    doAnotherThing(c, d);
}

Voici à quoi je suppose que la pile ressemble à la ligne X:

Stack

a +-------------+
  | 1           | 
b +-------------+     
  | 2           |  
c +-------------+
  | 3           | 
d +-------------+     
  | 4           | 
  +-------------+ 

Maintenant, tout ce que j'ai lu sur le fonctionnement de la pile est qu'elle obéit strictement aux règles LIFO (dernier entré, premier sorti). Tout comme un type de données de pile dans .NET, Java ou tout autre langage de programmation.

Mais si c'est le cas, que se passe-t-il après la ligne X? Parce qu'évidemment, la prochaine chose dont nous avons besoin est de travailler avec a et b, mais cela signifierait que l'OS/CPU (?) Doit sortir d et c d'abord pour revenir à a et b. Mais alors il se tirerait dans le pied, car il a besoin de c et d dans la ligne suivante.

Alors, je me demande ce que exactement se passe dans les coulisses?

Une autre question connexe. Considérons que nous passons une référence à l'une des autres fonctions comme celle-ci:

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(&a, &b);
    doAnotherThing(c, d);
}

D'après ma compréhension, cela signifierait que les paramètres dans doSomething pointent essentiellement vers la même adresse mémoire comme a et b dans foo. Mais là encore, cela signifie qu'il n'y a pas de pop-up jusqu'à ce que a et b se produisent.

Ces deux cas me font penser que je n'ai pas complètement compris comment exactement la pile fonctionne et comment elle suit strictement le [~ # ~] lifo [~ # ~ ] règles.

96
Christoph

La pile d'appels pourrait également être appelée une pile de trames.
Les choses qui sont empilées après le LIFO ne sont pas les variables locales mais la pile entière cadres ("appels") des fonctions appelées . Les variables locales sont poussées et sautées avec ces cadres dans le soi-disant fonction prologue et épilogue , respectivement.

À l'intérieur du cadre, l'ordre des variables n'est pas spécifié; Compilateurs "réorganiser" 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 travaillez avec les décalages de cette adresse aux variables. Une telle adresse d'ancrage est en fait contenue dans la soi-disant base ou pointeur de trame qui est stocké dans le registre EBP. Les décalages, d'autre part, sont clairement connus au moment de la compilation et sont donc codés en dur dans le code machine.

Ce graphique de Wikipedia montre à quoi ressemble la pile d'appels typique1:

Picture of a stack

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. Si 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 aux variables locales.

cin est alors déplacé dans le registre EDI2 et get est appelé; La valeur de retour est en EAX.

Jusqu'ici tout va bien. Maintenant, la chose intéressante se produit:

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-à-dire -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 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. Cela est dû à l'instruction implicite return. Vous pouvez également voir xorl rax rax Au lieu de movl.
  • partir et retourner sur le site de l'appel. leave est l'abréviation de cet épilogue et implicitement
    • Remplace le pointeur de pile par le pointeur de base et
    • Ouvre le pointeur de base.

Après cette opération et ret ont été effectuées, le cadre a été effectivement sauté, même si l'appelant doit encore nettoyer les arguments que nous utilisons la convention d'appel cdecl. Autres conventions, par ex. stdcall, obliger l'appelé à ranger, par ex. en passant le nombre d'octets à ret.

Omission du pointeur de trame

Il est également possible de ne pas utiliser à la place 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 débogage impossible sur certaines machines , et sera désactivé implicitement 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 à part cela. Pour plus d'informations, voir 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 qui commencent par R sont les homologues 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é.

112
Columbo

Parce qu'évidemment, la prochaine chose dont nous avons besoin est de travailler avec a et b mais cela signifierait que l'OS/CPU (?) Doit d'abord sortir d et c pour revenir à a et b. Mais alors il se tirerait une balle dans le pied car il a besoin de c et d dans la ligne suivante.

En bref:

Il n'est pas nécessaire de faire éclater les arguments. Les arguments passés par l'appelant foo à la fonction doSomething et les variables locales dans doSomething peuvent tous être référencés comme un décalage par rapport au - pointeur de base.
Alors,

  • Lorsqu'un appel de fonction est effectué, les arguments de la fonction sont PUSHed sur la pile. Ces arguments sont en outre référencés par le pointeur de base.
  • Lorsque la fonction revient à son appelant, les arguments de la fonction renvoyée sont POPés à partir de la pile à l'aide de la méthode LIFO.

En détail:

La règle est que chaque appel de fonction entraîne la création d'un cadre de pile (le minimum étant l'adresse à laquelle retourner). Ainsi, si funcA appelle funcB et funcB appelle funcC, trois cadres de pile sont configurés l'un au-dessus de l'autre. Lorsqu'une fonction revient, son cadre devient invalide . Une fonction au bon comportement n'agit que sur son propre cadre de pile et n'empiète pas sur celle d'un autre. En d'autres termes, le POPing est effectué sur le cadre de pile en haut (lors du retour de la fonction).

enter image description here

La pile de votre question est configurée par l'appelant foo. Lorsque doSomething et doAnotherThing sont appelés, ils configurent leur propre pile. La figure peut vous aider à comprendre ceci:

enter image description here

Notez que, pour accéder aux arguments, le corps de la fonction devra traverser vers le bas (adresses plus élevées) depuis l'emplacement où l'adresse de retour est stockée, et pour accéder aux variables locales, le corps de la fonction avoir à parcourir la pile (adresses inférieures) par rapport à l'emplacement où l'adresse de retour est stockée . En fait, le code généré par le compilateur typique pour la fonction fera exactement cela. Le compilateur lui consacre un registre appelé EBP (Base Pointer). Un autre nom pour le même est pointeur de trame. Le compilateur, généralement, en tant que première chose pour le corps de la fonction, pousse la valeur EBP actuelle sur la pile et définit l'EBP sur l'ESP actuel. Cela signifie que, une fois cela fait, dans n'importe quelle partie du code de fonction, l'argument 1 est EBP + 8 loin (4 octets pour chacun des EBP de l'appelant et l'adresse de retour), l'argument 2 est EBP + 12 (décimal), les variables locales sont EBP-4n.

.
.
.
[ebp - 4]  (1st local variable)
[ebp]      (old ebp value)
[ebp + 4]  (return address)
[ebp + 8]  (1st argument)
[ebp + 12] (2nd argument)
[ebp + 16] (3rd function argument) 

Jetez un œil au code C suivant pour la formation du cadre de pile de la fonction:

void MyFunction(int x, int y, int z)
{
     int a, int b, int c;
     ...
}

Lorsque l'appelant l'appelle

MyFunction(10, 5, 2);  

le code suivant sera généré

^
| call _MyFunction  ; Equivalent to: 
|                   ; Push eip + 2
|                   ; jmp _MyFunction
| Push 2            ; Push first argument  
| Push 5            ; Push second argument  
| Push 10           ; Push third argument  

et le code d'assemblage de la fonction sera (configuré par l'appelé avant de revenir)

^
| _MyFunction:
|  sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
|  ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
|  ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] =   [esp]
|  mov ebp, esp
|  Push ebp

Références:

25
haccks

Comme d'autres l'ont noté, il n'est pas nécessaire de faire apparaître les paramètres jusqu'à ce qu'ils sortent du champ d'application.

Je vais coller un exemple de "Pointeurs et mémoire" de Nick Parlante. Je pense que la situation est un peu plus simple que vous ne l'aviez imaginé.

Voici le code:

void X() 
{
  int a = 1;
  int b = 2;

  // T1
  Y(a);

  // T3
  Y(b);

  // T5
}

void Y(int p) 
{
  int q;
  q = p + 2;
  // T2 (first time through), T4 (second time through)
}

Les points dans le temps T1, T2, etc. sont marqués dans le code et l'état de la mémoire à ce moment est indiqué dans le dessin:

enter image description here

18
user2793162

Différents processeurs et langages utilisent plusieurs conceptions de pile différentes. Deux modèles traditionnels sur le 8x86 et le 68000 sont appelés la convention d'appel Pascal et la convention d'appel C; chaque convention est gérée de la même manière dans les deux processeurs, à l'exception des noms des registres. Chacun utilise deux registres pour gérer la pile et les variables associées, appelés le pointeur de pile (SP ou A7) et le pointeur de trame (BP ou A6).

Lors de l'appel d'un sous-programme à l'aide de l'une ou l'autre convention, tous les paramètres sont poussés sur la pile avant d'appeler la routine. Le code de la routine pousse ensuite la valeur actuelle du pointeur de trame sur la pile, copie la valeur actuelle du pointeur de pile vers le pointeur de trame et soustrait du pointeur de pile le nombre d'octets utilisés par les variables locales [le cas échéant]. Une fois cela fait, même si des données supplémentaires sont poussées sur la pile, toutes les variables locales seront stockées au niveau des variables avec un déplacement négatif constant du pointeur de pile, et tous les paramètres qui ont été poussés sur la pile par l'appelant peuvent être accédés à un déplacement positif constant du pointeur de trame.

La différence entre les deux conventions réside dans la manière dont elles gèrent une sortie du sous-programme. Dans la convention C, la fonction de retour copie le pointeur de trame vers le pointeur de pile [le restaure à la valeur qu'il avait juste après que l'ancien pointeur de trame a été poussé], affiche l'ancienne valeur de pointeur de trame et effectue un retour. Tous les paramètres que l'appelant avait poussés sur la pile avant l'appel y resteront. Dans la convention Pascal, après avoir sauté l'ancien pointeur de trame, le processeur saute l'adresse de retour de la fonction, ajoute au pointeur de pile le nombre d'octets de paramètres poussés par l'appelant, puis passe à l'adresse de retour sautée. Sur le 68000 d'origine, il était nécessaire d'utiliser une séquence de 3 instructions pour supprimer les paramètres de l'appelant; les processeurs 8x86 et 680x0 après l'original incluaient une instruction "ret N" [ou équivalent 680x0] qui ajouterait N au pointeur de pile lors d'un retour.

La convention Pascal a l'avantage de sauvegarder un peu de code du côté de l'appelant, car l'appelant n'a pas à mettre à jour le pointeur de pile après un appel de fonction. Il nécessite cependant que la fonction appelée sache exactement combien d'octets de paramètres l'appelant va mettre sur la pile. Ne pas pousser le nombre approprié de paramètres sur la pile avant d'appeler une fonction qui utilise la convention Pascal est presque garanti de provoquer un crash. Ceci est cependant compensé par le fait qu'un peu de code supplémentaire dans chaque méthode appelée enregistre le code aux endroits où la méthode est appelée. Pour cette raison, la plupart des routines de la boîte à outils Macintosh d'origine utilisaient la convention d'appel Pascal.

La convention d'appel C a l'avantage de permettre aux routines d'accepter un nombre variable de paramètres et d'être robuste même si une routine n'utilise pas tous les paramètres passés (l'appelant saura combien d'octets de paramètres il a poussés, et pourront ainsi les nettoyer). De plus, il n'est pas nécessaire d'effectuer un nettoyage de pile après chaque appel de fonction. Si une routine appelle quatre fonctions en séquence, chacune utilisant quatre octets de paramètres, elle peut - au lieu d'utiliser un ADD SP,4 après chaque appel, utilisez un ADD SP,16 après le dernier appel pour nettoyer les paramètres des quatre appels.

De nos jours, les conventions d'appel décrites sont considérées comme quelque peu dépassées. Comme les compilateurs sont devenus plus efficaces dans l'utilisation des registres, il est courant que les méthodes acceptent quelques paramètres dans les registres plutôt que d'exiger que tous les paramètres soient poussés sur la pile; si une méthode peut utiliser des registres pour contenir tous les paramètres et variables locales, il n'est pas nécessaire d'utiliser un pointeur de trame, et donc pas besoin d'enregistrer et de restaurer l'ancien. Néanmoins, il est parfois nécessaire d'utiliser les anciennes conventions d'appel lors de l'appel de bibliothèques liées pour les utiliser.

7
supercat

Il y a déjà de très bonnes réponses ici. Cependant, si vous êtes toujours préoccupé par le comportement LIFO de la pile, pensez-y comme une pile de cadres, plutôt qu'une pile de variables. Ce que je veux dire, c'est que, bien qu'un la fonction peut accéder à des variables qui ne sont pas en haut de la pile, elle ne fonctionne toujours que sur l'élément en haut de la pile: une seule pile Cadre.

Bien sûr, il y a des exceptions à cela. Les variables locales de l'ensemble de la chaîne d'appel sont toujours allouées et disponibles. Mais ils ne seront pas accessibles directement. Au lieu de cela, ils sont passés par référence (ou par pointeur, qui n'est vraiment différent que sémantiquement). Dans ce cas, une variable locale d'une trame de pile beaucoup plus bas est accessible. Mais même dans ce cas, la fonction en cours d'exécution ne fonctionne toujours que sur ses propres données locales. Elle accède à une référence stockée dans son propre cadre de pile, qui peut être une référence à quelque chose sur le tas, dans la mémoire statique, ou plus bas dans la pile.

C'est la partie de l'abstraction de la pile qui rend les fonctions appelables dans n'importe quel ordre et permet la récursivité. Le cadre de pile supérieur est le seul objet auquel le code accède directement. Tout le reste est accessible indirectement (via un pointeur qui vit dans le cadre de pile supérieur).

Il peut être instructif de regarder l'assemblage de votre petit programme, surtout si vous compilez sans optimisation. Je pense que vous verrez que tout l'accès à la mémoire dans votre fonction se fait via un décalage à partir du pointeur de cadre de pile, qui est la façon dont le code de la fonction sera écrit par le compilateur. Dans le cas d'un passage par référence, vous verriez des instructions d'accès indirect à la mémoire via un pointeur qui est stocké à un certain décalage du pointeur du cadre de pile.

5
Jeremy West

La pile d'appels n'est pas réellement une structure de données de pile. Dans les coulisses, les ordinateurs que nous utilisons sont des implémentations de l'architecture de machine à accès aléatoire. Ainsi, a et b sont directement accessibles.

Dans les coulisses, la machine:

  • obtenir "a" équivaut à lire la valeur du quatrième élément sous le sommet de la pile.
  • obtenir "b" équivaut à lire la valeur du troisième élément sous le sommet de la pile.

http://en.wikipedia.org/wiki/Random-access_machine

4
hdante