Programmation système 04 – La pile d'exécution

À l'origine, ce texte a été construit à l'aide de Visual Studio 6, un vieil outil, sur une machine 32 bits. Ainsi, vous y trouverez des affirmations quant à la taille de certains types, affirmations qui peuvent ne pas s'avérer sur votre ordinateur ou avec votre compilateur. Supposez donc, pour les fins de la discussion :

Évidemment, sizeof(char)==1 sur toutes les plateformes.

Le code machine proposé en exemple dans ce qui suit provient d'un compilateur qu'on pourrait qualifier d'ancien (le texte a été écrit au début des années 2000). L'objectif visé ici est d'illustrer le lien entre le code source et le code généré, sans plus.

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, ou .obj, et l'édition des liens assure la jonction de ces modules objet en un exécutable, ou .exe.

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++, 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 x86, 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 1re], la troisième sera à CS+[taille 1re+taille 2e], 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 :

Par exemple :

Avant le chargement Après le chargement
jmp main+55h
jmp 004012a5

main+55h est une adresse relative à main. Si on ne sait pas où est main, alors on ne sait pas ce que signifie main+55h.

004012a5 est une adresse absolue, donc qu'on connaît avec précision.

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 :

Exemples d'éléments du contexte

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

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... À quelle instruction en était-on dans l'exécution de la fonction appelante au moment de l'appel? (exprimé autrement : 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.

É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 C++.

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é Visual Studio 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.

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é.

Pour ce qui est du code généré, le travail à faire correspond donc en gros à ce qui suit :

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 Visual Studio (version 6) qui présente le code suivant :

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.

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

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: 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 :

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.

Étape 2, ligne: 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 :

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: 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 éléments à noter sont :

É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 :

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 d'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 :

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 :

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 qu'un type primitif.

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 :

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 modifier 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 bytes... 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 bytes sur une machine 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 :

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 valeur de retour est produite selon une mécanique plutôt sympathique :

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. C'est fort raisonnable 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, remarquez 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.

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 bytes, 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, ou 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ébogueur de visualk Studio 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).

Prenons par exemple le programme C++ suivant :

1:    int somme(int);
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.

La plus grosse différence entre un paramètre effectif et une variable automatique, sur le plan de la mécanique, est que :

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 telle qu'elle était au début du sous-programme, et que 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 :

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 x86.

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.

Lectures complémentaires

Quelques liens pour enrichir le propos.


[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 variadiques, donc 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 3e, le 12e ou le 60e 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.


Valid XHTML 1.0 Transitional

CSS Valide !