La pile d'exécution

Notre portrait de ce qu'est la mécanique de compilation, d'édition des liens et d'exécution d'un programme se clarifie. Le dernier gros morceau du casse-tête que nous couvrirons en ce sens dans le cours est d'expliquer ce qui se produit lors d'un appel de sous-programme, ce que représente la mécanique de passage de paramètres en C++, et ce qui se produit lors de la conclusion d'une fonction.

Pour être en mesure d'expliquer ces dernières considérations de manière compréhensible, il importe d'abord de faire un retour sur l'ensemble de la mécanique d'exécution d'un programme, pour voir où s'introduisent les problèmes propres aux sous-programmes, et pour comprendre les outils utilisés (souvent à notre insu) pour les régler.

Génération et exécution d'un programme

La compilation d'un module source (en C++, les sources portent généralement l'extension «.cpp») résulte en un module objet «.obj», et l'édition des liens assure la jonction de ces modules objet en un exécutable «.exe».

  • Un exécutable est un fichier qui peut être chargé en mémoire, puis dont le segment de code peut être exécuté, donc traité instruction par instruction à partir d'un point d'entrée bien précis (qui est, en C++, le sous-programme principal «main()»).
  • On peut voir l'exécution d'un programme sans sous-programmes comme une suite d'instructions en code machine (ou en langage d'assemblage) contiguës en mémoire, parcourues une à une consécutivement, sauf dans les cas où des instructions de branchement (ou de saut) sont rencontrées.

    Le code à droite montre les instructions C++ sur fond gris, suivi du code assembleur généré par le compilateur VC6.0.

    
    int a, b= 0, c= 3;
    00401268   mov  dword ptr [ebp-8],0
    0040126F   mov  dword ptr [ebp-0Ch],3
    b = c + 2;
    00401276   mov  eax,dword ptr [ebp-0Ch]
    00401279   add  eax,2
    0040127C   mov  dword ptr [ebp-8],eax
    a = c * b;
    0040127F   mov  ecx,dword ptr [ebp-0Ch]
    00401282   imul ecx,dword ptr [ebp-8]
    00401286   mov  dword ptr [ebp-4],ecx

    Structures de contrôle

    Si un programme ne se composait que d'une suite linéaire d'instructions, la programmation deviendrait fort ardue. Heureusement, nous avons des outils qui nous permettent de faire des choix en fonction de conditions précises, et de répéter des séquences d'instructions sous certaines conditions.

    Les instructions de branchement (requises pour les alternatives et les répétitives) introduisent un bris dans la séquence, et sont prises (ou non) en fonction du résultat d'une comparaison.

    Alternative

    En assembleur IntelMD, l'instruction de comparaison en langage d'assemblage se nomme «CMP», pour «Compare».

    Lorsqu'une instruction de saut conditionnel[1] est rencontrée, la séquence de code pourra être brisée, et la prochaine instruction pourra être ailleurs dans le code du programme (à l'endroit dicté par la logique du programme, en fait).

    Lorsqu'on rencontrera un saut inconditionnel «JMP», le séquence de code sera brisée.

    
    if (c < b)
    00401289   mov  edx,dword ptr [ebp-0Ch]
    0040128C   cmp  edx,dword ptr [ebp-8]
    0040128F   jge  main+4Ch (0040129c)
    {
       a = a - c;
    00401291   mov  eax,dword ptr [ebp-4]
    00401294   sub  eax,dword ptr [ebp-0Ch]
    00401297   mov  dword ptr [ebp-4],eax
    }
    else
    0040129A   jmp  main+55h (004012a5)
    {
       a = a - b;
    0040129C   mov  ecx,dword ptr [ebp-4]
    0040129F   sub  ecx,dword ptr [ebp-8]
    004012A2   mov  dword ptr [ebp-4],ecx
    }
    Rappel: le pointeur d'instruction

    Le registre IP contient l'adresse de la prochaine instruction machine à traiter. Un instruction de saut dans le programme, lorsqu'elle est suivie, force une valeur spécifique dans ce registre.

    Pour être exact, IP ne contient pas l'adresse de la prochaine instruction à exécuter, mais bien la distance (on dit souvent offset) en octets depuis le début du segment de code (CS).

    La première instruction d'un programme est à CS+0 (on écrira «CS:0»); la seconde sera à «CS+[taille de la 1ière]», la troisième sera à «CS+[taille de la 1ière + taille de la 2ième]», etc.

    Rappel: adresse relatives vs adresses absolues

    On trouve normalement des adresses relatives à la fonction «main()» (exemple: «main+55h» au lieu de quelque chose comme 004012a5) dans les exemples de code assembleur que nous avons vu jusqu'ici, plutôt que des adresses absolues.

    La raison est que tant que le code exécutable n'est pas chargé en mémoire, à un endroit précis, il est impossible de savoir précisément quelles les adresses réelles des différentes instructions du programme.

    Ainsi:

  • quand le programme est inerte (entreposé sur disque, par exemple), les adresses qu'on y retrouve sont exprimées sous forme relative à un endroit donné (par exemple, relative à l'endroit où début le programme principal);
  • puis, au moment où le programme est chargé en mémoire, il se trouve placé quelque part. Sachant où est le programme, on sait où se trouve le programme principal et, par conséquent, il devient possible de calculer les adresses absolues (réelles) correspondant à chacune des adresses relatives.
  • Par exemple:

    Avant le chargement

    Après le chargement

    
    jmp main+55h
    
    jmp 004012a5

    main+55h est une adresse relative au «main» (si on ne sait pas où est «main», on ne sait pas ce que signifie «main+55h»

    004012a5 est une adresse absolue (on connaît précisément cette valeur)

    Répétitive

    Une répétitive, comme une alternative, impliquera au niveau du code machine des instructions de comparaison et de branchement.

    Il faut toutefois être prudent en lisant du code généré par un compilateur. La logique sera respectée, mais la manière peut être surprenante (une condition de poursuite en C++ sera, si elle se trouve au début d'une répétitive, inversée pour générer une condition de sortie en assembleur).

    
    while (c > 0)
    004012A5   cmp  dword ptr [ebp-0Ch],0
    004012A9   jle  main+66h (004012b6)
    {
       c--;
    004012AB   mov  edx,dword ptr [ebp-0Ch]
    004012AE   sub  edx,1
    004012B1   mov  dword ptr [ebp-0Ch],edx
    }
    004012B4   jmp  main+55h (004012a5)

    Lorsqu'on introduira des sous-programmes dans le modèle de programmation, toutefois, se produiront des situations que nous n'avons pas encore étudié ...

    La pile d'exécution d'un processus

    Nous savons que les instructions d'un programme se retrouvent dans un espace mémoire qu'on nommera son segment de code (Code Segment). Ses données globales se retrouveront à un endroit qu'on nommera son segment de données (Data Segment)

    En plus, chaque processus[2] se voit attribué un espace pour sa pile d'exécution. Vous avez peut-être déjà vu ce qu'est la structure de données qu'on nomme «pile»; la pile d'exécution d'un processus en est un cas très concret.

  • Nous parlerons ici de fonction appelante, soit celle où un appel de fonction est fait, et de fonction appelée, soit celle qui est l'objet de l'appel. Souvent, on parlera simplement d'appelant et d'appelé.
  • Mise en situation

    Supposons une procédure «main()» qui appelle un sous-programme[3] «f1()». L'exécution du programme suivra les étapes suivantes:

  • «appel» de «main()». On se retrouve au début de la fonction appelée (le «main()» en tant que tel);
  • appel de «f1()» à partir d'un endroit précis de la fonction «main()». On quitte cet endroit dans la fonction appelante pour se retrouver au début de la fonction appelée (au début de «f1()»);
  • fin de la fonction «f1()» et retour à l'endroit d'où «f1()» avait été appelée dans la fonction appelante (ici, à la ligne de l'appel dans le «main()») et l'exécution se poursuit de cet endroit;
  • fin de la fonction «main()». L'exécution du processus se termine.
  • Cela paraît à première vue simple et naturel. La solution naïve serait de faire, en langage d'assemblage, à peu près comme suit:

    
    main:  instruction1
           instruction2
           jmp [f1] ; aller au début du sous-programme «f1»
    après: instruction3 ; après f1
           instruction4
    fin:   int 20   ; fin du programme
    f1:    instr_f11  ; début de f1
           instr_f12
           instr_f13
           jmp après: ; revenir au lieu de l'appel

    Mais cette solution ne fonctionne que si «f1()» ne peut être appelée que du programme principal, puisqu'une fois «f1()» terminé, on retourne nécessairement à l'endroit que nous avons ici identifié comme «après:».

    Le contexte

    Dans la vraie vie, l'appel d'un sous-programme donné peut se faire à plusieurs endroits dans un programme. La solution naïve est donc insuffisante pour traiter les cas réels.

    Il ne suffit donc pas de sauter ailleurs en mémoire pour aller d'une fonction appelante à une fonction appelée? Par exemple: comment le code sait-il à quelle «ligne» de l'appelant revenir lorsque se termine la fonction appelée?

    L'exemple suivant illustre cette question:

    La fonction «g()» peut être appelée et de la fonction «main()», et de la fonction «f()». Ainsi, c'est le contexte de l'exécution du programme qui décidera, à tout moment, à quel endroit revenir lorsque la fonction «g()» terminera de s'exécuter.

    Ceci n'est pas un problème simple. Il ne serait pas raisonnable pour le solutionner de copier intégralement le code de l'appelé à l'endroit de chaque appel[4]. Il nous faut donc un autre mécanisme pour nous en sortir. Il faut:

  • que la fonction existe initialement en un endroit précis; et
  • qu'on ait un mécanisme permettant au programme de noter à l'appel dans quelles conditions la fonction devrait se retrouver lors de sa conclusion.
  • Exemples d'éléments du contexte

    Le contexte d'appel d'une fonction comprend plusieurs items importants. Nommons en deux, simplement pour démontrer notre point:

    Question:

    quelles étaient les valeurs des différents registres avant que la fonction soit appelée?

    C'est une question pertinente: si la fonction est appelée à l'intérieur d'une boucle «for», par exemple, la valeur du registre servant de compteur doit être protégée de façon à ce que la boucle se poursuive normalement, et ce peu importe le traitement fait dans la fonction appelée.

    Concrètement: une fonction utilisant le registre «ECX» appelle une fonction «Bingo()». La fonction «Bingo()» ne peut pas savoir que la fonction appelante utilisait le registre «ECX», mais elle sait qu'elle peut avoir besoin de ce registre pour son propre fonctionnement.

    Il faut donc qu'une copie du registre «ECX» soit faite avant l'appel, et que l'appelant puisse récupérer cette copie et la remettre dans «ECX» après l'appel pour que son propre fonctionnement demeure valide.

    La question se pose sous une lueur plus générale...

    Question: à quelle instruction en était-on dans l'exécution de la fonction appelante au moment de l'appel (i.e.: quelle était alors la valeur du registre «IP»)?

    Nous l'avons mentionné précédemment: si la valeur de «IP» n'est pas prise en note au moment de l'appel, l'endroit précis de l'appel sera perdu, et le retour au point de l'appel deviendra impossible.

    Justification de l'emploi d'une pile

    Nous avons donc besoin, pour être en mesure d'appeler un sous-programme, de conserver au préalable le contexte de l'appelant. La question naturelle qui se pose est donc: où conserver ces valeurs au moment de l'appel de telle façon qu'on puisse les récupérer lors de la conclusion de la fonction appelée?

    Il existe une structure de données dont l'emploi est naturel pour résoudre ce genre de problème: la pile. Le pseudo-code d'appel de fonction suivant l'illustre:

    
    // code avant l'appel
    Sauvegarder le contexte
    Sauvegarder le lieu de l'appel
       Début la fonction
       // code de la fonction
       Fin de la fonction
    Récupérer le lieu de l'appel
    Récupérer le contexte
    // code après l'appel

    Si nous avions une pile entre les mains, nous aurions naturellement pu écrire (en pseudo assembleur--sauvegarder ou récupérer le contexte prend plusieurs instructions!):

    
      ; code avant l'appel
      PUSH (contexte) ; mettre le contexte sur la pile
      «PUSH (IP)»      ; mettre l'adresse de retour sur la pile
      CALL (fonction) ; IP <-- adresse de «fonction»
      ; code de la fonction
      RET             ; IP <-- valeur sur la pile (adresse de retour)
      POP (contexte)  ; reprendre le contexte sur la pile
      ; code après l'appel

    Ce pseudo-code assembleur est incomplet (on y fait notamment abstraction des paramètres de la fonction appelée), mais montre que la mécanique d'appel de fonctions se prête tout naturellement à l'utilisation d'une pile. Et nous avons effectivement un tel outil: la pile d'exécution.

    Note: écrit tel quel, ce code ne fonctionne pas, car «PUSH IP» n'a pas de sens--cette instruction étant une instruction assembleur, elle incrémenterait IP, brisant la logique du code. En réalité, c'est l'instruction CALL qui conserve la valeur de IP, et c'est l'instruction RET qui la récupère.

    Qu'est-ce que la pile d'exécution d'un processus?

    La pile d'exécution d'un processus est un espace en mémoire alloué à ce processus, et dont le contenu fluctue à chaque changement de contexte. Elle sert particulièrement à gérer les appels de sous-programmes, le passage des paramètres et les variables dites «automatiques[5]» en langage C ou en langage C++.

    Note:

    La pile d'exécution est d'abord, comme son nom l'indique, une pile, c'est-à-dire une structure de données dotée de certaines particularités très utiles.

    Pour comprendre le cas particulier de la pile d'exécution, il faut s'assurer de comprendre au moins de façon intuitive le cas général d'une pile. La page suivante offre un court schéma de révision du concept.

    La pile d'appels

    L'environnement intégré DevStudio de Visual C++ offre entre autres choses un outil appelé la pile d'appel (call stack). Celle-ci ne présente pas tous les détails propres à la pile d'exécution, mais permet de suivre (sous la forme d'une pile) la séquence d'appels de fonctions avec le dévermineur.

    Dans l'image ci-dessus, on voit par exemple que la fonction «main()» a appelé à sa sixième ligne de code la fonction «somme()», qui elle en est à la première ligne de son propre code. Lorsque bien utilisé, la pile d'appels permet de retracer la séquence d'appels qui mène à une situation donnée, et peut s'avérer d'une grande utilité lorsqu'on cherche à comprendre «ce qui s'est passé pour en arriver à cette situation de c**»...

    Une pile

    Une pile c'est un lieu où on peut entreposer et d'où on peut accéder à des objets. On peut se demander si elle est vide, si elle est pleine[6], et on peut y ajouter (push) ou en enlever (pop) un objet à la fois.

  • Lorsqu'on veut ajouterPUSH») un objet à une pile, on l'ajoute toujours au dessus de la pile, jamais ailleurs. C'est pourquoi l'analogie classique est celle d'une pile d'assiettes: on se voit mal ajouter ailleurs qu'au-dessus de la pile dans ce cas, quand bien même ce ne serait que pour fins de simplicité.
  • Lorsqu'on veut enleverPOP») un objet d'une pile, on enlève toujours celui du dessus de la pile, jamais un autre. L'analogie de la pile d'assiettes tient: retirer un objet au milieu ou en dessous de la pile risquerait de la faire s'écrouler.
  • Ces propriétés relativement symétriques d'ajout et de retrait sont fondamentales à la pile, et en font la structure essentielle qu'elle est en informatique.

    En effet, pensons un instant à tout ce qui est ainsi symétrique dans le Monde Merveilleux de l'InformatiqueMD:

  • un début et une fin à chaque programme et à chaque sous-programme;
  • les parenthèses, qui doivent être balancées (une parenthèse fermante pour chaque parenthèse ouvrante) et dont le contenu doit obéir à une certaine structure;
  • un début et une fin à chaque bloc (accolades ouvrantes et fermantes), et donc à chaque contexte...
  • L'appel d'un sous-programme

    Chaque appel de sous-programme implique au moins deux acteurs: l'appelant et l'appelé.

  • L'appelant est le contexte duquel est produit l'appel de la fonction. Toute fonction C ou C++ peut être appelante, dans la mesure où elle appelle une autre fonction. L'appelant décide des valeurs précises des paramètres à passer, et utilise la valeur de retour produite par la fonction appelée (s'il y a lieu) à ses propres fins.
  • L'appelé est le contexte de la fonction qui est appelée (par l'appelant, ça va de soi). Il reçoit les paramètres offerts par l'appelant et produit (s'il y a lieu) un résultat sous forme de valeur de retour.
  • Pour ce qui est du code généré, le travail à faire correspond donc en gros à ce qui suit:

  • s'assurer que l'appel soit correct, donc qu'il respecte le prototype de la fonction appelée (tâche du compilateur) et que la fonction appelée existe vraiment dans l'un des «.obj» à lier ensembles (tâche de l'éditeur de liens);
  • s'assurer que les paramètres soient correctement (dans le bon ordre) passés de l'appelant à l'appelé au moment de l'appel, et correctement (dans le bon ordre) récupérés par l'appelé une fois terminée l'exécution du sous-programme;
  • s'assurer que le contexte d'exécution passe de celui de l'appelant à celui de l'appelé là où a lieu l'appel de la fonction en question: «sauter» du lieu de l'appel au début de la fonction appelée en opérant un changement de contexte;
  • lorsque le contexte de l'appelé se termine (lors de la rencontre dans son code d'une instruction «return» ou encore d'une accolade fermante), s'assurer que le contrôle revienne à l'appelant, et au bon endroit dans le flot de son exécution (au lieu de l'appel), et s'assurer, dans le cas où la fonction produit une valeur de retour, que celle-ci sera disponible et accessible à l'appelant.
  • Pas une mince tâche, n'est-ce pas? Mais la pile d'exécution est l'outil tout désigné pour permettre l'implantation élégante de cette mécanique.

    Exemple concret d'appel de fonction

    Voyons un peu ce qui se passe: nous avons ici un programme «désassemblé» par VC qui présente le code suivant:

  • une fonction «Somme(int,int)» qui retourne un entier;
  • une fonction «main()» qui appelle «Somme()».
  • Voici une vue d'ensemble de notre programme:

    
    1:    int Somme (int a, int b);
    2:
    3:    int main ()
    4:    {
    5:       int c= Somme (5, 3);
    6:    }
    7:
    8:    int Somme (int a, int b)
    9:    {
    10:      return (a + b);
    11:   } // Somme

    Nous ferons la lecture de ce code selon l'ordre dans lequel il sera parcouru, pour mieux en comprendre les diverses subtilités.

    Note: les numéros de ligne du code C++ ont été laissés dans le code présenté pour vous aider à mieux vous orienter.

    Note: certaines instructions sont moins pertinentes à notre étude. C'est pourquoi nous ne couvrirons pas la totalité du code assembleur présenté, nous restreignant aux éléments étudiés.
    
    1:    int Somme (int a, int b);
    2:
    3:    int main ()
    4:    {
    00401020   push        ebp
    00401021   mov         ebp,esp
    00401023   sub         esp,44h
    00401026   push        ebx
    00401027   push        esi
    00401028   push        edi
    00401029   lea         edi,[ebp-44h]
    0040102C   mov         ecx,11h
    00401031   mov         eax,0CCCCCCCCh
    00401036   rep stos    dword ptr [edi]
    5:       int c= Somme (5, 3);
    00401038   push        3
    0040103A   push        5
    0040103C   call        @ILT+0(Somme) (00401005)
    00401041   add         esp,8
    00401044   mov         dword ptr [ebp-4],eax
    6:    }
    00401047   pop         edi
    00401048   pop         esi
    00401049   pop         ebx
    0040104A   add         esp,44h
    0040104D   cmp         ebp,esp
    0040104F   call        __chkesp (004010a0)
    00401054   mov         esp,ebp
    00401056   pop         ebp
    00401057   ret

    Étape 1, ligne «3»: la fonction «main()»

    Nous avons jusqu'ici escamoté les premières lignes de la fonction «main()», dans le but avoué de nous simplifier l'existence. Toutefois, bien que cette fonction soit le point d'entrée de l'exécutable résultant de l'édition des liens dans un projet, il est facile d'oublier qu'elle demeure un sous-programme et, au même titre que les autres sous-programme, son appel est soumis à une certaine mécanique.

    La ligne «3» du programme en exemple est:

    
    3:    int main ()
    4:    {
    00401020   push        ebp
    00401021   mov         ebp,esp
    00401023   sub         esp,44h
    00401026   push        ebx
    00401027   push        esi
    00401028   push        edi
    00401029   lea         edi,[ebp-44h]
    0040102C   mov         ecx,11h
    00401031   mov         eax,0CCCCCCCCh
    00401036   rep stos    dword ptr [edi]

    Lorsque le point d'entrée du «main()» est rencontré, on voit trois lignes de code assembleur bien spéciales apparaître, dont deux sont vraiment essentielles à un appel de fonction correct:

  • l'instruction «push ebp» dépose la valeur du registre «EBP» sur la pile. Le registre «EBP» offre l'adresse de base pour les calculs d'adressage au niveau du code assembleur.
  • Chaque sous-programme utilise sa propre base (sa propre valeur de «EBP»), qui diffère de celle des autres sous-programmes; il faut donc absolument conserver sur la pile une copie de la base de calcul de la fonction appelante avant de la modifier pour qu'elle devienne celle de la fonction appelée.

    C'est à partir de l'adresse indiquée par «EBP» que le sous-programme en cours d'exécution retrouvera ses variables locales. Avoir une valeur inexacte dans ce registre, ne serait-ce que d'un seul octet, ferait en sorte que le programme tout entier devienne soudainement incorrect.

  • l'instruction «mov ebp,esp» définit la valeur de «EBP» pour la fonction appelée comme étant celle du dessus de la pile d'exécution. Ainsi, les informations propres au fonctionnement de la fonction seront déposées sur la pile;
  • les autres «push» et «mov» sont plus particuliers à l'implantation, et moins intéressants pour notre étude.
  • Étape 2, ligne «6»: la fin de la fonction «main()»

    La ligne «6» nous offre la situation inverse: le sous-programme «main()» se termine et doit rétablir l'état de la pile de telle façon que la fonction appelante--quelle qu'elle soit--puisse retomber correctement sur ses pattes.

    Les instructions nécessaires, en symétrie avec celles à l'étape 1 plus haut, sont donc:

    
    6:    }
    00401047   pop         edi
    00401048   pop         esi
    00401049   pop         ebx
    0040104A   add         esp,44h
    0040104D   cmp         ebp,esp
    0040104F   call        __chkesp (004010a0)
    00401054   mov         esp,ebp
    00401056   pop         ebp
    00401057   ret

    On voit donc que:

  • l'instruction «mov esp,ebp» assure un retour à la valeur initiale de «ESP», peu importe la suite de «push» et de «pop» générés entre-temps. Puisqu'à l'appel d'une fonction on trouve nécessairement «mov ebp,esp», il va de soi que (si personne n'a eu la mauvaise idée de déposer des valeurs suspectes dans «EBP» pendant l'exécution de la fonction) l'instruction inverse rétablira la valeur de «ESP»;
  • l'instruction «pop ebp» récupère l'ancienne valeur de «EBP» telle que sauvegardée sur la pile par l'instruction «push ebp» au début de la fonction, restaurant ainsi l'état initial du registre «EBP».
  • Note: effectuer ces deux opérations fait en sorte de remettre en place les valeurs du «EBP» et de «ESS» telles qu'elles étaient avant le début du «main()», pour que le sous-programme l'ayant appelé retrouve son contexte.

    Nous reviendrons sur le rôle de l'instruction «ret» sous peu, alors que nous examinerons l'instruction «call».

    Étape 3, ligne «5»: l'appel de la fonction «Somme()»

    La ligne «5» nous permet de voir explicitement la mécanique d'un appel de fonction, dans ce cas la fonction «Somme()».

    Nous trouvons donc ici:

    
    5:       int c= Somme (5, 3);
    00401038   push        3
    0040103A   push        5
    0040103C   call        @ILT+0(Somme) (00401005)
    00401041   add         esp,8
    00401044   mov         dword ptr [ebp-4],eax

    Les items à noter sont:

  • les instructions «push 3» et «push 5» qui, dans l'ordre, déposent la valeur du 2ième, puis du 1er paramètre sur la pile d'exécution. Ainsi, c'est à travers la pile d'exécution que les paramètres formels sont passés de l'appelant à l'appelé lors de l'appel d'un sous-programme;
  • toujours en lien avec le passage de paramètres, remarquez qu'ils sont déposés du dernier au premier. Il y a une raison technique à cela[7];
  • l'appel de fonction en tant que tel est accompli par l'instruction «call». Remarquez son premier (et seul) opérande: c'est l'adresse où on trouve les instructions propres à la fonction appelée. C'est d'ailleurs cette instruction qui cachera «l'adresse de retour» de la fonction appelée sur la pile (un paramètre caché, si vous voulez), de façon telle que l'instruction «ret», sa jumelle, puisse faire la récupérer et bondir au lieu de l'appel lorsque terminera vraiment l'exécution de la fonction appelée;
  • l'instruction «add esp,8» équivaut à sauter par-dessus les paramètres déposés sur la pile, qui sont déposés par les «push» précédents mais ne sont ici vraiment utiles qu'au sous-programme appelé. On saute de «8» parce que la taille totale des paramètres passés à l'appel (deux «int») est de huit (8) octets[8];
  • toujours en lien avec cette dernière instruction, notez qu'on ajoute à la valeur du registre «ESP» pile pour faire l'équivalent d'un «pop»; on ne soustrait pas. C'est en fait une mesure de sécurité: la pile d'exécution est définie «à l'envers»: elle débute à une distance connue du code du processus en tant que tel, et diminue pour revenir vers lui (ainsi, si un débordement de la pile se produit, le processus qui souffrira sera le processus responsable, pas les autres);
  • finalement, l'instruction «mov dword ptr [c],eax» dépose à l'adresse représentant la variable «c» la valeur sur 32 bits de «EAX». Ceci est en fait la version assembleur de l'affectation C++ «c=Somme(3,5)»; nous déduisons donc que c'est dans le registre «EAX» qu'on retrouve la valeur de retour de la fonction «Somme()».
  • Étape 4, lignes «8» à «11»: la fonction appelée («Somme()»)

    Examinons finalement ce qui se passe dans le cadre de la fonction appelée:

    
    8:    int Somme (int a, int b)
    9:    {
    00401070   push        ebp
    00401071   mov         ebp,esp
    00401073   sub         esp,40h
    00401076   push        ebx
    00401077   push        esi
    00401078   push        edi
    00401079   lea         edi,[ebp-40h]
    0040107C   mov         ecx,10h
    00401081   mov         eax,0CCCCCCCCh
    00401086   rep stos    dword ptr [edi]
    10:      return (a + b);
    00401088   mov         eax,dword ptr [ebp+8]
    0040108B   add         eax,dword ptr [ebp+0Ch]
    11:   } // Somme
    0040108E   pop         edi
    0040108F   pop         esi
    00401090   pop         ebx
    00401091   mov         esp,ebp
    00401093   pop         ebp
    00401094   ret

    Les lignes «9:» et «11:» suivent la même logique que les lignes «4:» et «6:» couvertes précédemment dans le cadre du sous-programme «main()».

    La ligne «10:», toutefois, nous montre (de manière obscure, ce qui est triste) comment sont gérés les paramètres dans le cadre de leur utilisation dans la fonction appelée:

  • remarquez d'abord que les instructions «MOV» et «ADD» qu'on voit inscrites dans le code de la fonction n'ont rien d'extraordinaire;
  • par contre, notez que ces instructions utilisent «dword ptr [ebp+8]» et «dword ptr [ebp+0ch]» pour décrire les paramètres «a» et «b» respectivement. Mais nous savons que les valeurs associées aux paramètres se trouvent en fait sur la pile d'exécution. Comment la fonction fait-elle pour les retrouver?
  • Le schéma suivant montre l'état de la pile d'exécution au moment où la fonction appelée doit accéder aux paramètres «a» et «b»:

    Où sont les paramètres «a» et «b»? Pour comprendre, voici l'équation pour trouver l'adresse d'un paramètre:

    valeur de «ESS-EBP» (rappel: «EBP=ESP» après les deux premières instructions d'une fonction) à laquelle on enlève la taille de deux mots mémoire (car au-dessus de la pile se trouvent alors l'ancienne valeur de «EBP», de même que l'adresse de retour empilée par «CALL» et destinée à être dépilée par «RET»), puis à laquelle on enlève encore la taille des paramètres précédents (s'il y a lieu).

    Dans notre cas:

  • on trouve «a» à: «EBP-(2*4)-0» (le «0» tenant du fait que «a» est le dernier paramètre empilé, et se trouve conséquemment sur le dessus de la pile); et
  • on trouve «b» à: «EBP-(2*4)-4» (le «4» étant la taille de «a»--un «int»--qui se trouve par-dessus «b»).
  • Le sous-programme ne dépile pas ses paramètres: il triche sur la structure de la pile en allant voir directement à une adresse où elle sait pouvoir trouver les paramètres qui lui sont confiés. Il sait que l'adresse de retour se trouve sur le dessus de la pile (juste au dessus des paramètres) au moment où il débute son exécution, et il sait avoir empilé la valeur de «EBP» pour l'appelant tout juste au-dessus.

    Donc, pour une vue d'ensemble...

    Offrons-nous donc une vue d'ensemble des manipulations requises pour générer le code assembleur représentant un appel de fonction C++ correct:

  • au besoin, l'appelant empile son contexte de travail (les registres dont il fait usage au moment de l'appel et dont l'altération provoquerait une exécution incorrecte). Nous n'avons pas vu d'exemples de ce genre encore, mais on parle essentiellement d'une série d'instructions «push» en fonction des besoins[9];
  • l'appelant empile les valeurs de ses paramètres. Les paramètres y sont déposés du dernier au premier, cela parce que seul l'appelant sait avec certitude le nombre exact de paramètres à passer;
  • l'appelant empile (via «call») l'adresse où poursuivre l'exécution une fois l'appel résolu, et provoque un saut au début du code propre à l'appelé;
  • l'appelé sauvegarde la valeur de «EBP» pour l'appelant, et modifie cette valeur pour ses propres fins;
  • l'appelé s'exécute, et accède à ses paramètres directement de la pile, sans les dépiler, puisqu'ils se trouvent sous l'adresse de retour et les valeurs empilées par l'appelé;
  • l'appelé restaure la valeur de «EBP» pour l'appelant;
  • l'appelant saute par dessus ses propres paramètres tels que déposés sur la pile, ces derniers n'étant pas utiles pour lui;
  • si la fonction appelée n'est pas de type «void», l'appelant récupère son résultat, se trouvant généralement dans le registre «EAX» (mais voir plus loin pour certains exemples sur des variables plus complexes que de simples entiers);
  • au besoin, l'appelant dépile son contexte de travail (les registres dont il fait usage au moment de l'appel et dont l'altération provoquerait une exécution incorrecte). Nous n'avons pas vu d'exemples de ce genre encore, mais on parle essentiellement d'une série d'instructions «pop» en fonction des besoins.
  • Valeurs de retour, pointeurs, références

    Étudions maintenant, de manière sommaire, les conventions de passage de paramètres et de valeurs de retour par adresse (pointeur), par référence et par valeur, mais pour un type de données autre que fondamental.

    Soit la déclaration de type structuré suivante:

    
    1:    struct sl { long l1, l2; };

    ... les prototypes suivants:

    
    3:    sl f (sl& s); // passage de paramètre par référence
    4:    sl g (sl* s); // passage de paramètre par adresse
    5:    sl h (sl s);  // passage de paramètre par valeur

    qui déclarent chacun une fonction qui retourne une copie d'un «sl», et finalement la fonction «main()» suivante:

    
    7:    int main ()
    8:    {
    9:       sl l= { 0, 0 },
    10:         r; // "s" est initialisé, "r" ne l'est pas
    11:      r= f (l);   // passage par référence d'un "sl"
    12:      r= g (&l);  // passage par adresse   d'un "sl"
    13:      r= h (l);   // passage par valeur    d'un "sl"
    14:   }

    Regardons ensembles les différences entre chacune. Avant tout, remarquons que les prototypes sont associés par le compilateur à un endroit en mémoire se trouvant avant le début du programme principal (pouvez-vous imaginer pourquoi?):

    
    @ILT+0(?Somme@@YAHHH@Z):
    00401005   jmp         Somme (00401070)
    @ILT+5(_main):
    0040100A   jmp         main (0040b4f0)
    0040100F   jmp         h (0040b610)
    00401014   jmp         g (0040b5d0)
    00401019   jmp         f (0040b590)

    Passage d'un objet par référence

    Le passage d'un objet par référence (en exemple ici: la fonction «f()») implique des manipulations un peu particulières.

    À l'appel:

    
    11:      r= f (l);   // passage par référence d'un "sl"
    0040B516   lea         eax,[ebp-8]
    0040B519   push        eax
    0040B51A   call        @ILT+20(f) (00401019)
    0040B51F   add         esp,4
    0040B522   mov         dword ptr [ebp-18h],eax
    0040B525   mov         dword ptr [ebp-14h],edx
    0040B528   mov         ecx,dword ptr [ebp-18h]
    0040B52B   mov         dword ptr [ebp-10h],ecx
    0040B52E   mov         edx,dword ptr [ebp-14h]
    0040B531   mov         dword ptr [ebp-0Ch],edx

    Quelques explications:

  • l'instruction «lea eax,dword ptr [l]» (load effective address) charge l'adresse de l'étiquette «l» (attribuée par le compilateur à la variable de type «sl» déclarée dans le «main()») dans le registre «EAX», adresse qui sera ensuite empilée (le passage de paramètre!) par l'instruction «push eax»;
  • En effet, un passage de paramètre par référence est, de manière déguisée, un passage de paramètre par adresse. On déposera en fait sur la pile l'adresse de l'objet auquel une référence est passée, et le compilateur fera les démarches nécessaires pour dissimuler cet état de fait au programmeur C++.

    C'est parce que le sous-programme appelé connaît l'adresse de l'objet passé en paramètre à l'appel qu'il peut altérer sa valeur.

  • l'instruction «call» appelle la fonction, de la même manière que pour les fonctions déjà couvertes dans ce document. Remarquez que l'escamotage des paramètres sur la pile (le «add esp,4» suivant l'instruction «call») ne saute que 4 octets... cela parce qu'à l'appel, on n'a empilé qu'une simple adresse;
  • Le passage de paramètres par référence tend donc à être très efficace en terme de performance (surtout lorsque comparé à un passage de paramètre par valeur sur des objets complexes). C'est moins lourd de passer en paramètre l'adresse d'un «struct» ou d'un objet arbitrairement complexe--de ne passer que 4 octets, donc 32 bits--que de passer une copie complète de cet objet.

  • les instructions «mov dword ptr [ebp-18h],eax» et «mov dword ptr [ebp-14h],edx» demandent un peu d'explications:
  • la fonction «f()» retourne une copie d'un «sl»;
  • les valeurs que cette fonction produit sont déposées dans les registres «eax» (pour le membre «l1») et «edx» (membre «l2»)--ceci est visible dans le code du sous-programme, plus loin;
  • la variable locale «r», qui reçoit le résultat, a ses propres membres «l1» à l'adresse «[ebp-18h]» et «l2» à l'adresse «[ebp-14h]».
  • Dans le cas simple d'une fonction qui retourne un «int», la valeur de retour sera souvent dans «eax».

    Dans la fonction appelée, on trouve:

    
    16:   sl f (sl& s)
    17:   {
    0040B590   push        ebp
    0040B591   mov         ebp,esp
    0040B593   sub         esp,40h
    0040B596   push        ebx
    0040B597   push        esi
    0040B598   push        edi
    0040B599   lea         edi,[ebp-40h]
    0040B59C   mov         ecx,10h
    0040B5A1   mov         eax,0CCCCCCCCh
    0040B5A6   rep stos    dword ptr [edi]
    18:      s.l1= 3;
    0040B5A8   mov         eax,dword ptr [ebp+8]
    0040B5AB   mov         dword ptr [eax],3
    19:      s.l2= 4;
    0040B5B1   mov         ecx,dword ptr [ebp+8]
    0040B5B4   mov         dword ptr [ecx+4],4
    20:      return s;
    0040B5BB   mov         edx,dword ptr [ebp+8]
    0040B5BE   mov         eax,dword ptr [edx]
    0040B5C0   mov         edx,dword ptr [edx+4]
    21:   }
    0040B5C3   pop         edi
    0040B5C4   pop         esi
    0040B5C5   pop         ebx
    0040B5C6   mov         esp,ebp
    0040B5C8   pop         ebp
    0040B5C9   ret

    Remarquez ce qui suit:

  • la fonction récupère l'adresse de «s», prise à même la pile, dans un registre (tout à tour «EAX» et «ECX» dans ce cas-ci). Les deux premières affectations, «s.l1=3;» et «s.l2=4;» génèrent le code assembleur «met dans un registre l'adresse de s» et «met à l'endroit où se trouve le membre spécifié dans s la valeur voulue»:
  • le membre «l1» de «s» est à l'adresse où se trouve «s» (donc à «s + 0»), alors que le membre «l2» de «s» est à l'adresse de «s» à laquelle on ajoute la taille de «l1» (soit «s + 4» car «l1» est un «long»--32 bits, ou 4 octets).
  • la valeur de retour est produite selon une mécanique plutôt sympathique:
  • on dépose d'abord dans un registre l'adresse de l'objet dont on souhaite faire une copie (avec l'instruction «mov edx,dword ptr [ebp+8]» où «[ebp+8]» est l'endroit où se trouve l'adresse à laquelle réfère «s»);
  • on utilise ensuite l'adresse détenue par le registre «edx» pour déposer dans «EAX» et «EDX» les valeurs décrivant le «sl» à retourner (ses membres «l1» et «l2»). La valeur de retour «complète», donc, sera ici la combinaison des registres «EAX» et «EDX».
  • C'est important de faire cette distinction parce que même si le paramètre «s» est une référence à l'objet original, la valeur de retour de la fonction est une copie de ce paramètre (et donc un objet distinct).

    Passage d'un objet par adresse

    Le passage d'un objet par adresse (en exemple ici: la fonction «g()») se génère à peu près comme suit.

    À l'appel:

    
    12:      r= g (&l);  // passage par adresse   d'un "sl"
    0040B534   lea         eax,[ebp-8]
    0040B537   push        eax
    0040B538   call        @ILT+15(g) (00401014)
    0040B53D   add         esp,4
    0040B540   mov         dword ptr [ebp-20h],eax
    0040B543   mov         dword ptr [ebp-1Ch],edx
    0040B546   mov         ecx,dword ptr [ebp-20h]
    0040B549   mov         dword ptr [ebp-10h],ecx
    0040B54C   mov         edx,dword ptr [ebp-1Ch]
    0040B54F   mov         dword ptr [ebp-0Ch],edx

    La seule remarque pertinente vraiment à faire ici est que, mis à part certaines distinctions numériques dues à l'emplacement légèrement différent en mémoire de certaines instructions, un appel de fonction avec paramètre passé par adresse est virtuellement identique, au niveau du code machine, à un appel de fonction avec paramètre passé par référence--ce qui a beaucoup de sens quand on réalise que dans un passage de paramètres par référence, c'est en fait l'adresse du paramètre qui, en cachette, est passée au sous-programme.

    Les fonctions «f()» et «g()» retournant toutes deux des copies de «sl», leurs mécaniques respectives de génération/ récupération des valeurs de retour sont aussi pratiquement identiques.

    Dans la fonction appelée, on trouve:

    
    23:   sl g (sl* s)
    24:   {
    0040B5D0   push        ebp
    0040B5D1   mov         ebp,esp
    0040B5D3   sub         esp,40h
    0040B5D6   push        ebx
    0040B5D7   push        esi
    0040B5D8   push        edi
    0040B5D9   lea         edi,[ebp-40h]
    0040B5DC   mov         ecx,10h
    0040B5E1   mov         eax,0CCCCCCCCh
    0040B5E6   rep stos    dword ptr [edi]
    25:      s-> l1= 3;
    0040B5E8   mov         eax,dword ptr [ebp+8]
    0040B5EB   mov         dword ptr [eax],3
    26:      s-> l2= 4;
    0040B5F1   mov         ecx,dword ptr [ebp+8]
    0040B5F4   mov         dword ptr [ecx+4],4
    27:      return *s;
    0040B5FB   mov         edx,dword ptr [ebp+8]
    0040B5FE   mov         eax,dword ptr [edx]
    0040B600   mov         edx,dword ptr [edx+4]
    28:   }
    0040B603   pop         edi
    0040B604   pop         esi
    0040B605   pop         ebx
    0040B606   mov         esp,ebp
    0040B608   pop         ebp
    0040B609   ret

    À nouveau, remarque«que le code machine généré pour deux fonctions, une manipulant une référence à un «sl» et l'autre manipulant un pointeur de «sl», si elles accomplissent la même fonction, est essentiellement identique.

    Note: Les références sont un outil qui permet de profiter d'une partie de la puissance des adresses et des pointeurs en C++ mais sans devoir courir les risques inhérents à la manipulation de pointeurs. Ce sont là deux outils de programmation extrêmement puissants.

    Passage d'un objet par valeur

    Le passage d'un objet par valeur (en exemple ici: la fonction «h()») se génère à peu près comme suit.

    À l'appel:

    
    13:      r= h (l);   // passage par valeur    d'un "sl"
    0040B552   mov         eax,dword ptr [ebp-4]
    0040B555   push        eax
    0040B556   mov         ecx,dword ptr [ebp-8]
    0040B559   push        ecx
    0040B55A   call        @ILT+10(h) (0040100f)
    0040B55F   add         esp,8
    0040B562   mov         dword ptr [ebp-28h],eax
    0040B565   mov         dword ptr [ebp-24h],edx
    0040B568   mov         edx,dword ptr [ebp-28h]
    0040B56B   mov         dword ptr [ebp-10h],edx
    0040B56E   mov         eax,dword ptr [ebp-24h]
    0040B571   mov         dword ptr [ebp-0Ch],eax

    Encore une fois, le mécanisme de récupération des valeurs produites par la fonction est virtuellement identique à celui présenté pour les fonctions «f()» et «g()», ce qui est absolument normal (le contraire serait pour le moins suspect).

    Notez par contre que le paramètre passé par valeur étant un «struct» composé de deux «long», il faut déposer (ici par deux «push») sur la pile huit (8) octets, soit la description complète de la variable «l» au moment de l'appel («l.l1» et «l.l2»).

    Dans la fonction appelée, on trouve:

    
    30:   sl h (sl s)
    31:   {
    0040B610   push        ebp
    0040B611   mov         ebp,esp
    0040B613   sub         esp,40h
    0040B616   push        ebx
    0040B617   push        esi
    0040B618   push        edi
    0040B619   lea         edi,[ebp-40h]
    0040B61C   mov         ecx,10h
    0040B621   mov         eax,0CCCCCCCCh
    0040B626   rep stos    dword ptr [edi]
    32:      s.l1= 3;
    0040B628   mov         dword ptr [ebp+8],3
    33:      s.l2= 4;
    0040B62F   mov         dword ptr [ebp+0Ch],4
    34:      return s;
    0040B636   mov         eax,dword ptr [ebp+8]
    0040B639   mov         edx,dword ptr [ebp+0Ch]
    35:   }
    0040B63C   pop         edi
    0040B63D   pop         esi
    0040B63E   pop         ebx
    0040B63F   mov         esp,ebp
    0040B641   pop         ebp
    0040B642   ret

    Le code de la fonction elle-même, qui manipule une copie (se trouvant décrite sur la pile) du paramètre formel, est relativement simple: pas besoin de charger une adresse dans un registre et de faire des manipulations comme celles nécessitées pour les paramètres par référence ou par adresse.

    On retrouve donc, pour un paramètre passé par valeur, une version similaire mais simplifiée des accès aux membres de la structure manipulée, celle-ci n'étant qu'une copie de l'original se trouvant sur la pile.

    Variables locales (automatiques) d'une fonction

    Un dernier détail digne de mention: l'attribution de l'espace pour les variables automatiques locales à une fonction.

    Comme dans le cas de la méthodologie de gestion des paramètres effectifs d'une sous-programme, le code assembleur présenté par le dévermineur de VC ne nous est pas d'un grand secours pour ce qui est de présenter comment l'attribution de l'espace propre aux variables locales est géré au niveau du code machine.

    Toutefois, il peut à l'occasion devenir important de comprendre ce mécanisme (tout(e) informaticien(ne) rencontre un problème relié à un débordement de pile à un moment donné ou l'autre dans sa carrière J).

    Prenons par exemple le programme C++ suivant:

    
    1:    int Somme (int a);
    2:    int main ()
    3:    {
    4:      int x= 3;
    5:      x= Somme (x);
    6:    }

    La fonction «Somme()» possède une variable locale «b» pour laquelle on doit réserver un espace de la taille d'un «int». Où donc réservera-t-on cet espace si on veut que ce programme fonctionne, dans le cas général?[10]

    Vous vous en doutez sûrement: cet espace est attribué sur la pile d'exécution. C'est en fait le seul endroit répondant aux besoins de flexibilité propres à la mécanique d'attribution d'espace pour les variables automatiques.

    Paramètres effectifs et variables automatiques

    Les paramètres effectifs (ceux vus et manipulés par la fonction appelée) et les variables automatiques de la fonction appelée sont tous deux gérés de la même manière.

  • Les paramètres formels sont ceux utilisés par l'appelant;
  • Les paramètres effectifs sont ceux utilisés par l'appelé.
  • La plus grosse différence entre un paramètre effectif et une variable automatique, sur le plan de la mécanique, est que:

  • les paramètres effectifs dépendent des valeurs empilées par l'appelant:
  • l'appelant rend disponible, de par la pile d'exécution du processus, les paramètres formels; puis
  • la copie empilée des paramètres formels devient en quelque sorte les paramètres effectifs de l'appelé.
  • les variables automatiques de la fonction appelée sont sa propre responsabilité:
  • l'appelant ne sait pas combien de variables automatiques utilisera l'appelé, leur taille ou quoi que ce soit d'autre à leur sujet;
  • l'espace propre aux variables automatiques est donc réservé sur la pile par l'appelé, par-dessus les paramètres tels qu'empilés dans le cadre de la mécanique normale de l'appel de la fonction.
  • Ainsi, la fonction «Somme()» suivante:

    
    7:    int Somme (int a)
    8:    {
    9:      int b = 5;
    10:     return b + a;
    11:   }

    amènera le code suivant:

    
    7:    int Somme (int a)
    8:    {
    0040B590   push        ebp
    0040B591   mov         ebp,esp

    ... et en escamotant des instructions moins pertinentes à notre propos...

    
    9:      int b = 5;
    0040B5A8   mov         dword ptr [ebp-4],5
    10:     return b + a;
    0040B5AF   mov         eax,dword ptr [ebp-4]
    0040B5B2   add         eax,dword ptr [ebp+8]
    11:   }
    0040B5B5   pop         edi
    0040B5B6   pop         esi
    0040B5B7   pop         ebx
    0040B5B8   mov         esp,ebp
    0040B5BA   pop         ebp
    0040B5BB   ret

    On note donc que, puisque «ebp» contient la valeur de «esp» tel qu'elle était au début du sous-programme, la variable automatique «b» se trouve au-dessus de la pile (à «[ebp-4]», en se souvenant que la pile va en descendant, donc que les valeurs inférieures à «ebp» sont considérées sur la pile, pas en dessous).

    En résumé

    Un processus est un programme en cours d'exécution, muni de ses données.

    Tout processus possède une pile d'exécution. Les opérations en assembleur permettant d'y empiler et d'en dépiler des données sont respectivement «PUSH» et «POP».

    La pile d'exécution d'un processus rend possible:

  • l'appel de fonctions, et le retour au bon endroit dans la fonction appelante une fois terminée l'exécution de la fonction appelée;
  • le passage des paramètres (par adresse, par référence, par valeur);
  • l'attribution d'espace propre aux variables locales (automatiques) dans la fonction appelée;
  • la conservation du contexte (en particulier des registres «EBP» et «ESP») de la fonction appelante avant l'appel, et sa récupération une fois terminée l'exécution de la fonction appelée.
  • La valeur de retour d'une fonction ne se trouve toutefois pas offerte à la fonction appelante via la pile. En général, on passera ici par des registres.

    Les calculs d'adressage de variables à l'intérieur d'une fonction sont faits relativement au registre de base, nommé «EBP» en assembleur Intel.

    La valeur du registre de base doit être conservée, lors d'un appel de sous-programme, par le sous-programme appelée. Celui-ci en remplacera ensuite le contenu par la valeur du registre de pile «ESP», ceci parce que c'est du dessus de la pile qu'est calculé l'emplacement des différentes variables automatiques d'un sous-programme. Chaque sous-programme voit «ESP» et «EBP» différemment; c'est ce qui permet toute la dynamique d'exécution du programme.

    
    push ebp
    mov ebp,esp

    Tout sous-programme doit restaurer la valeur du registre de base une fois son exécution terminée, pour que le sous-programme appelant poursuive ses opérations normales.

    
    mov esp,ebp

    Les références sont en fait des adresses déguisées.

    L'instruction assembleur «CALL» empile l'adresse à laquelle revenir une fois terminée l'exécution de la fonction appelée. L'instruction «RET» récupère cette adresse et y saute, assurant la poursuite normale des opérations du processus.


    [1] Instruction débutant par la lettre «J», pour «Jump», mais autre que «JMP», qui est un saut inconditionnel.

    [2] Rappel: un processus est un programme en cours d'exécution, muni de ses données.

    [3] Nous utiliserons ici «fonction» au sens large de sous-programme, dans la tradition du langage C.

    [4] En fait, ce serait impossible, pour différentes raisons bien concrètes.

    [5] Les variables automatiques sont les variables locales aux fonctions que vous utilisez maintenant de manière relativement naturelle.

    [6] Une pile sera pleine si on ne peut plus y empiler d'objets, faute d'espace. La manière de déterminer ceci varie selon l'implantation de pile choisie.

    [7] puisque les langages C et C++ permettent de déclarer des fonctions ayant un nombre variable de paramètres, il faut absolument que le premier paramètre passé soit celui se trouvant le plus près du dessus de la pile une fois l'appel effectué. En effet, dans le cas contraire, il serait impossible de savoir si le paramètre «du dessus» est vraiment le 1er, le 3ième, le 12ième ou le 60ième paramètre du sous-programme.

    [8] pas besoin de faire de «pop» si on se fout des valeurs en question; l'appelant n'a qu'à sauter par dessus l'espace occupé par les paramètres.

    [9] Il y a deux écoles de pensée: ceux qui disent que l'appelant devrait empiler les registres dont il se sert pour éviter qu'ils soient bousillés par l'appelé, parce que c'est l'appelant qui sait ce dont il se sert; et l'autre qui dit que l'appelé devrait empiler les registres dont il se servira parce que c'est l'appelé qui sait ce qu'il utilisera. Les deux approches se défendent.

    [10] ... donc dans le cas où la fonction peut être appelé de plusieurs endroits, et où il devient impossible de prédire avec exactitude quand et comment elle sera effectivement appelée.