Programme d'étude sur le C++ bas niveau n° 3 : la Pile

L'objectif de cette série d'articles d'Alex Darby sur la programmation « bas-niveau » est de permettre aux développeurs ayant déjà des connaissances de la programmation C++ de mieux comprendre comment ses programmes sont exécutés en pratique. Ce troisième article explique le rôle et le fonctionnement de la Pile, son usage lors de l'appel d'une fonction, la gestion des variables locales ainsi que la gestion de la valeur de retour d'une fonction.

Retrouver l'ensemble des articles de cette série sur la page d'index.

N'hésitez pas à commenter cet article ! 8 commentaires Donner une note à l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Bienvenue dans la troisième partie de cette série d'articles que j'écris, le « Programme d'étude sur le C++ bas niveau ».

Cet article traite de la Pile (Stack en anglais) qui est sans doute l'élément sous-jacent le plus important du C++. Si vous ne deviez apprendre qu'un seul aspect au sujet du « C++ bas niveau », mon conseil serait que cela soit la Pile.

Vous n'avez probablement pas besoin de lire les deux premiers articles pour comprendre cette partie mais je supposerai - définitivement - que vous n'avez pas peur de la fenêtre affichant du code machine.

Au cas où vous souhaiteriez lire les deux premiers articles, voici les liens :

II. Prologue

Si vous êtes un développeur C++ et que vous n'êtes pas sûr de ce qu'est la Pile ou de comment elle fonctionne, alors vous n'êtes pas seul.

La Bjible
La Bjible

Le livre de Bjarne Stroustrup « C++ Programming Language (3rd edition) » - qui est plus ou moins le texte de référence du C++ (du moins jusqu'à ce que la mise à jour de la norme C++11 ne soit publiée…) - ne discute pas de ce qu'est la Pile ou de comment elle fonctionne, même s'il se réfère à des données ou des objets placés « sur la Pile » comme si le lecteur savait ce que cela signifie.

Les informations les plus concrètes concernant la Pile dans la Bjible (1) se trouvent dans une section de l'Annexe C intitulée « C.9 Gestion de la mémoire » :

  • « Mémoire automatique » : mémoire dans laquelle les arguments des fonctions et les variables locales sont alloués. Chaque entrée de fonction ou bloc de code obtient sa propre copie. Ce type de mémoire est automatiquement créé et détruit, d'où le nom « mémoire automatique ». La « mémoire automatique » est « placée sur la Pile ». Si vous devez absolument être explicite à ce sujet, le C++ fournit le mot-clé « auto » pour cela.

Ne vous méprenez pas, ce livre est un excellent livre (et un de ceux auxquels je me réfère très souvent), mais le fait qu'il ignore quelque chose d'aussi profond que le fonctionnement de la Pile avec les opérations du C++ est un signe. De par mon expérience, c'est symptomatique de la déconnexion existante dans la mentalité académique entre le langage de programmation et la mise en œuvre sous-jacente.

Durant mes études en informatique, le concept de la Pile était abordé par quelques diapositives dans un module obligatoire de première année appelé « Systèmes informatiques et architecture », mais jamais spécifiquement en relation avec les langages de programmation que nous apprenions et c'est pourquoi, cher lecteur, je me sens obligé d'écrire à ce sujet…

III. Qu'est-ce que la Pile ?

Sans surprise, la Pile est une structure de données en pile (2). Par souci de clarté, je l'écrirai « Pile » pour la distinguer de n'importe quelle autre instance d'une structure de données en pile.

Dans un programme à thread unique, la Pile contient la majorité des données relatives à l'état d'exécution courant du programme et toute la mémoire non globale « automatique » sous contrôle du compilateur - par exemple, les variables locales, les paramètres des fonctions, etc.

Quand vous posez un point d'arrêt dans votre code à l'aide de votre environnement de développement préféré et que vous utilisez la fenêtre « Pile des appels » pour afficher la pile des appels de fonction pour atteindre votre point d'arrêt, les données affichées par cette fenêtre sont presque certainement générées par le débogueur en examinant l'état de la Pile.

Les détails spécifiques de la manière dont la Pile fonctionne varient d'une CPU à l'autre, d'une machine à l'autre, d'un compilateur à l'autre et même avec le même compilateur d'une option de compilation à l'autre (plus d'information à ce sujet dans un prochain article).

D'une manière générale, chaque fois qu'une nouvelle fonction est appelée :

  • l'état courant d'exécution de la CPU (c'est-à-dire les valeurs instantanées des registres du processeur) est sauvegardé dans la Pile de sorte qu'il peut être rétabli plus tard ;
  • tout ou partie des paramètres attendus par la fonction appelée sont mis dans la Pile (de nombreuses implémentations utilisent les registres pour le passage de paramètres lorsque cela est possible) ;
  • la CPU saute à un nouvel emplacement en mémoire pour exécuter le code de la fonction appelée.

De manière générale, la Pile contient les informations suivantes :

  • les variables locales et les paramètres de la fonction en dessous de la pile des appels de fonction ;
  • une copie du contenu des registres du processeur utilisés par la fonction après l'appel à cette fonction en dessous de la pile des appels de fonction ;
  • l'adresse mémoire de la prochaine instruction à exécuter lorsque cette fonction se termine (l'adresse de retour) en dessous de la pile des appels de fonction.

La zone de la Pile qui contient les variables locales de la fonction (et toutes les autres données que l'on pourrait mettre dedans) est appelée « Stack Frame » en anglais . C'est un terme très important, ne l'oubliez pas !

Clairement, le fonctionnement de la pile est très important pour l'exécution de votre code et il est désormais facile de comprendre pourquoi quelque chose d'aussi simple que d'écrire en dehors des limites d'un tableau déclaré local à l'intérieur d'une fonction peut causer une telle pagaille - l'écriture hors des limites est fortement susceptible d'écraser l'adresse de retour de la fonction ou toute autre valeur essentielle à la bonne marche du programme une fois que la fonction actuelle se termine.

IV. Comment fonctionne la Pile en pratique ?

Pour répondre à cette question, considérons ce programme (très simple) en C/C++ :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
int AddOneTo( int iParameter )
{
    int iLocal = iParameter + 1;
    return iLocal;
}
 
int main( int argc, char** argv )
{
    int iResult = 0;
    iResult = AddOneTo( iResult );
    return 0;
}

Je m'aiderai de ce programme en tant qu'application console win32 construit en utilisant VS2010 dans une configuration de débogage avec plus ou moins les options par défaut du compilateur et de l'éditeur de liens. Les captures d'écran dans cet article en sont le reflet.

Je vous conseille aussi de le refaire vous-même à la main après avoir lu cet article car il est toujours beaucoup plus pédagogique de se débrouiller avec quelque chose que vous avez écrit plutôt que de le lire…

Comme je l'ai mentionné plus tôt, les spécificités détaillées du fonctionnement de la Pile - en particulier ce qui concerne la transmission de paramètres à des fonctions - dépendent des options du compilateur que vous utilisez. Ces différences sont pour la plupart définies par un ensemble de normes de génération de code qui sont appelées « conventions d'appel », chaque convention a ses propres règles sur la façon dont les paramètres sont passés aux fonctions et comment les valeurs sont renvoyées. Certaines conventions ont tendance à être plus rapides que d'autres, mais la plupart sont conçues pour répondre aux exigences spécifiques pour transmettre des données - telles que l'appel en C++ d'une fonction membre ou un nombre variable de paramètres (par exemple, printf()).

La convention d'appel par défaut utilisée par VS2010 pour les fonctions en C (pas de pointeur this) est connue sous le nom stdcall et puisque nous parlons d'une version debug, le désassemblage que nous verrons utilisera une convention stdcall non optimisée. Cette convention d'appel met tout sur la Pile à l'exception de la valeur de retour de fonction qui est renvoyée via le registre eax.

Si vous utilisez ce code sur une machine non « wintel » (3), le fonctionnement et l'organisation de la Pile dans le code généré par le compilateur et la façon dont les paramètres sont transmis seront presque certainement - voire nettement - différents de ce que je vous montre dans mon débogueur, mais les mécanismes fondamentaux par lesquels elle travaille devraient être sensiblement les mêmes.

V. Configuration

Pour commencer, créez un projet console win32 vide, créez dans ce projet un nouveau fichier .cpp (je l'appellerais main.cpp si j'étais vous) et coller dans ce fichier le code ci-dessus.

Ensuite, ouvrez les propriétés du projet et vérifiez que l'option « Basic Runtime Check » (4) est positionnée sur « Default ». Cela rend non seulement le code de débogage (beaucoup) plus rapide, mais simplifie également l'assembleur qu'il génère essentiellement - en particulier dans le cas de notre programme très simple. L'image ci-dessous montre ce à quoi les options de la boîte de dialogue devraient ressembler après avoir effectué ce changement.

Les information importantes sont encadrées en rouge !
Les informations importantes sont encadrées en rouge !

Posez un point d'arrêt sur la ligne 7 (oui, je sais, cette ligne est la définition de la fonction main()). Lancez la compilation et exécutez le code. Quand le point d'arrêt est atteint, faites un clic droit dans la fenêtre du code source et choisissez l'option « Go To Disassembly » (5).

Vous devriez maintenant voir quelque chose comme ceci (N.B. les adresses des instructions en bas à gauche de la fenêtre seront très certainement différentes dans votre fenêtre de désassemblage) :

Le désassembleur du code généré
Le désassembleur du code généré

Vérifiez que les options dans le menu du clic droit sont positionnées de la même manière.

Ne paniquez pas !

Clairement, c'est beaucoup plus redoutable que le désassemblage que nous avons examiné auparavant et donc, avant d'aller plus loin, nous allons examiner un peu la façon dont la Pile est gérée dans l'assembleur généré par le compilateur x86 32 bits (au moins par le compilateur C++ de VS2010 avec les options de compilation et d'édition de lien par défaut).

VI. Avant de commencer

Les premiers éléments d'information qui vont commencer à donner du sens sont que deux registres-clés du processeur sont généralement impliqués dans la gestion du « Stack Frame » en assembleur x86 32 bits.

  • esp : registre pointeur de Pile qui pointe toujours vers le « haut » de la Pile ;
  • ebp : registre de pointeur de base qui pointe vers le début (ou la base) de la « Stack Frame » actuelle.

Les variables locales sont généralement représentées avec un offset par rapport au registre ebp, dans ce cas iResult est stockée à l'adresse [ebp-4].

Si vous voulez voir les noms de variables locales plutôt que des décalages par rapport à ebp vous pouvez faire un clic droit et cocher l'option « Show Symbol Names » (6), mais sachez que les décalages par rapport à ebp sont négatifs.

Pourquoi le décalage des variables locales par rapport à ebp est-il négatif ? C'est parce que la pile des processeurs x86 grandit vers le bas dans la mémoire - à savoir le « haut » de la pile, elle est stockée dans une adresse mémoire inférieure à la « base ». Par conséquent, l'adresse stockée dans ebp est plus grande que l'adresse stockée en esp. Ainsi, les variables locales dans le « Stack Frame » présentent un décalage cadre de pile négatif par rapport à ebp (et un décalage positif par rapport à esp).

Je suis sûr que chaque machine avec laquelle j'ai travaillé avait une Pile qui grandit vers le bas plutôt que vers le haut dans l'espace d'adressage, mais pour autant que je sache, il n'y a pas de « Loi Universelle de l'Ordinateur » qui dit que la Pile doit faire comme cela - je suis sûr qu'il doit y avoir des machines qui ont des piles qui grandissent dans le sens inverse.

Bien que cela semble contre-intuitif, avoir une pile qui grandit vers le bas dans l'espace d'adressage est logique si l'on considère la disposition générale traditionnelle de la mémoire des programmes C/C++, ce que nous verrons dans un prochain article de la série traitant de la mémoire.

VII. Push et Pop

Comme je suis sûr que vous le savez déjà, les deux opérations-clés sur une structure de données abstraites appelée pile sont « d'empiler » (push) quelque chose sur le dessus de la pile (ce qui couvre le dernier « dessus »), ou de « dépiler » (pop) ce qui est sur le dessus de la pile (et ainsi, exposer le dessus « précédent »).

Bien sûr, le jeu d'instructions du microprocesseur x86 possède une instruction push et une instruction pop. Chacune de ces instructions utilise un registre en paramètre :

  • push décrémente esp de la taille de son opérande et recopie la valeur de cet opérande à la nouvelle adresse mémoire pointée par esp (c'est-à-dire sur le haut de la pile) ;
  • cela signifie qu'après l'instruction push, la valeur contenue à l'adresse mémoire pointée par esp contient la valeur de ce qui a été empilé sur la Pile ;
  • pop recopie la valeur de ce qui est à l'adresse mémoire pointée par esp dans son opérande puis incrémente esp de la taille de cet opérande de sorte que son opérande soit retiré de la Pile.

Ces comportements sont la clé de la manière dont fonctionne la Pile.

VIII. À quoi ressemble la Pile avant l'exécution de notre code ?

Comme je suis sûr que la plupart d'entre vous le savent, il y a un code qui s'exécute avant la fonction main(). Ce code est responsable de toutes sortes d'initialisations du système et quand il a fini, il appelle la fonction main() en passant les arguments de ligne de commande en tant que paramètres.

Regardons la forme de la Pile juste avant que la première instruction de la fonction main() ne soit exécutée - dans le schéma ci-dessous, j'ai appelé la fonction qui appelle main() « pre-main() ». Vous trouverez probablement que le nom de la fonction réelle dans la pile des appels de votre programme est une combinaison effrayante de soulignement et d'acronymes capitalisés.

Ce schéma aura plus de sens quand vous reviendrez sur ce paragraphe après avoir lu le reste de l'article.

Image non disponible
État de la Pile avant que notre code ne s'exécute

IX. Le préambule de la fonction (ou prologue)

Bien avant le moindre code consistant à attribuer 0 à iResult, nous avons une bonne quantité de code qui ne ressemble à rien de C/C++. Nous allons donc lui donner un sens.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
     7: int main( int argc, char** argv )
     8: {
01311280  push        ebp
01311281  mov         ebp,esp
01311283  sub         esp,44h
01311286  push        ebx
01311287  push        esi
01311288  push        edi

Un bloc d'assembleur très similaire à celui-ci existera au début de chaque fonction générée par le compilateur, ce bloc est généralement désigné comme le « préambule » de la fonction ou encore le « prologue ».

L'objectif de ce code est de créer un « Stack Frame » à la fonction et de stocker le contenu de tous les enregistrements que la fonction va utiliser afin que ces valeurs puissent être rétablies avant le retour de la fonction :

Les numéros de ligne utilisés ci-dessous renvoient aux numéros du code ci-dessus.

  • ligne 3 : recopie la valeur courante de ebp sur la pile avec l'instruction push ;
  • ligne 4 : recopie la valeur de esp dans ebp. ebp pointe maintenant directement sur l'ancienne valeur de ebp qui vient d'être empilée sur la Pile ;
  • ligne 5 : soustraction de la valeur 44h (68 en décimal) au registre esp. Ceci a pour effet de réserver 68 octets sur la Pile juste après ebp ;
  • lignes 6, 7 et 8 : recopie des valeurs des registres ebx, esi et edi sur la Pile avec l'instruction push. C'est parce que le code de la fonction main() utilise ces registres et qu'il faut être en mesure de les restituer à leurs anciennes valeurs lors du retour. Notez que chaque instruction empilée diminuera la valeur de esp de 4 octets.

Si vous suivez dans le débogueur, alors je vous conseille d'ouvrir la fenêtre des « registres » dans le débogueur et de regarder les changements de valeurs de ceux-ci au fur et à mesure que vous avancez dans le code. Vous feriez bien aussi d'avoir plusieurs fenêtres « mémoire » ouvertes pointant sur esp et ebp afin que vous puissiez suivre les changements en mémoire (pour obtenir une fenêtre mémoire dans VS2010 qui suive un registre, vous devrez cliquer sur le bouton « Reevaluate Automatically »(7) à droite de la zone « Address: » puis taper le nom du registre dans la zone d'édition).

À ce moment, la Pile devrait ressembler à ceci :

Image non disponible
État de la Pile après le préambule de la fonction main()

Quelques remarques à noter au sujet de ces instantanés de la Pile :

  • la valeur « T » en haut à gauche de ces instantanés est utilisée pour identifier les anciennes valeurs de ebp stockées dans la pile ;
  • différentes couleurs sont utilisées pour repérer quelle fonction est chargée de mettre les données sur la Pile (et donc de les enlever) ;
  • différentes nuances de couleur sont utilisées pour représenter les différents types de données sauvegardées sur la Pile par les fonctions (ex. le pointeur de base ebp (variables locales), les valeurs des registres sauvegardés, les paramètres, les adresses de retour).

X. Le postambule de la fonction (ou épilogue)

Une fois que le corps de la fonction a terminé son exécution, la Pile et les registres doivent être remis dans l'état où ils étaient avant le préambule de sorte que la fonction appelante puisse poursuivre son exécution là où elle s'était arrêtée.

C'est le travail du postambule (ou épilogue) de la fonction. Le postambule fait tout simplement l'opposé logique du préambule - il dépile tout ce que le préambule avait empilé et rétablit les valeurs qu'avaient esp et ebp avant l'exécution du préambule.

Dans le code ci-dessous, j'ai supprimé le corps de la fonction de sorte que le préambule et le code postambule soient adjacents. En regardant comme cela, il est clair que le postambule fait le contraire du préambule.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
     7: int main( int argc, char** argv )
     8: {
01311280  push        ebp
01311281  mov         ebp,esp
01311283  sub         esp,44h
01311286  push        ebx
01311287  push        esi
01311288  push        edi
... code représentant le corps de main() supprimé ...
    12: }
013112A1  pop         edi
013112A2  pop         esi
013112A3  pop         ebx
013112A4  mov         esp,ebp
013112A6  pop         ebp
013112A7  ret

La seule ligne dans le préambule qui n'a pas d'opposé direct dans le postambule est la ligne 5 (sub esp, 44h) - et c'est parce que la copie de ebp dans esp en ligne 14 annule les lignes 4 et 5.

Comme toutes les fonctions disposent d'un préambule et d'un postambule, la compréhension des deux paragraphes précédents nous permet maintenant d'ignorer l'essentiel de ce préambule et de ce postambule pour mieux se concentrer sur le code qui est différent dans chaque fonction.

XI. Et maintenant, le code que nous avons écrit !

Maintenant que nous avons compris le préambule et le postambule, nous pouvons nous concentrer sur le corps de la fonction main() et comment elle appelle la fonction AddOneTo().

Ceci est le premier morceau de code assembleur qui est directement corrélé avec le code que l'on peut voir au niveau C/C++.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
     9:     int iResult = 0;
01311289  mov         dword ptr [ebp-4],0  
    10:     iResult = AddOneTo( iResult );
01311290  mov         eax,dword ptr [ebp-4]  
01311293  push        eax  
01311294  call        0131101E  
01311299  add         esp,4  
0131129C  mov         dword ptr [ebp-4],eax

Donc, comme nous devrions tous être familiers avec le premier article du Programme d'étude sur le C++ bas niveau, la ligne 2 dans le code ci-dessus consiste à initialiser la variable iResult, que nous pouvons voir dans le Stack Frame courant à l'adresse [ebp-4] dans la Pile.

Le reste des instructions consiste en l'initialisation de l'appel à la fonction AddOneTo(), son appel et l'affectation de sa valeur de retour à iResult.

  • ligne 4 : copie de la valeur de iResult dans le registre eax ;
  • ligne 5 : empilement de la valeur de eax sur la Pile (ce qui décrémente aussi la valeur de esp). Ceci copie la valeur de iResult sur la Pile comme un paramètre à la fonction AddOneTo() ;
  • ligne 6 : appel de l'adresse 0131101Eh. Cette instruction permet à la fonction AddOneTo() d'être appelée. L'instruction call empile l'adresse 01311299h sur la Pile (c'est l'adresse mémoire de l'instruction à la ligne 7) et ensuite poursuit l'exécution du programme à l'adresse 0131101Eh ;
  • ligne 7 : Lorsque la fonction appelée par la ligne 6 se termine, le paramètre de la fonction empilé sur la Pile à la ligne 5 doit être enlevé afin que l'état de la Pile redevienne comme avant l'appel à AddOneTo(). Pour ce faire, nous ajoutons 4 à esp - ceci a le même effet sur esp que l'instruction pop mais nous n'avons plus besoin de la valeur sur la Pile et il est donc logique d'ajuster directement esp. Je suppose que c'est aussi plus efficace, mais je n'ai jamais regardé ;
  • ligne 8 : copie la valeur de eax dans [ebp-4] où nous savons que iResult est stocké. La convention d'appel stdcall pour le code x86 win32 précise que le registre eax est utilisé pour retourner les valeurs des fonctions, donc cette instruction copie la valeur de retour de AddOneTo() dans iResult.

XII. L'appel à AddOneTo()

Regardons à nouveau les instructions impliquées dans l'appel à la fonction AddOneTo() :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
     9:     int iResult = 0;
01311289  mov         dword ptr [ebp-4],0  
    10:     iResult = AddOneTo( iResult );
01311290  mov         eax,dword ptr [ebp-4]  
01311293  push        eax  
01311294  call        0131101E
  • une copie de la valeur iResult est empilée sur la Pile (par eax) comme paramètre pour AddOneTo() ;
  • cette instruction push sur la Pile déplace esp de 4 octets (ex. 32 bits) et stocke son opérande (le registre eax) à cette adresse. Après cette instruction la valeur de iResult est stockée à l'adresse pointée par esp ;
  • l'instruction call empile l'adresse de la prochaine instruction à exécuter après que la fonction appelée retourne (l'adresse de retour est 01311299h) sur la Pile et poursuit ensuite l'exécution à l'adresse 0131101Eh ;
  • une copie de la valeur de iResult est maintenant à [esp+4] et l'adresse de retour est à [esp].

À cet instant, la Pile ressemble à ceci :

Image non disponible
tat de la Pile juste après que l'appel à AddOneTo() a étéeffectué dans main()

Le code à l'adresse 0131101Eh qui a été appelé ressemble à ceci :

 
Sélectionnez
1.
2.
AddOneTo:
0131101E  jmp         01311250

Je dois avouer que c'est un peu confus. Cette instruction est tout simplement une nouvelle instruction de saut, cette fois, pour le code qui représente le corps de la fonction réelle de AddOneTo() qui est à l'adresse 01311250h. Pourquoi faire appel à une instruction qui fait un autre saut ?

Si vous regardez par vous-même, vous remarquerez que le code autour de cette fonction semble être une collection d'étiquettes de style goto. C'est exactement cela. Vous remarquerez également que les instructions associées à chaque étiquette sautent ailleurs. Il est clair que cela ressemble à une sorte de « table de sauts ».

La raison de ceci ? C'est parce que j'ai utilisé la configuration de débogage par défaut, l'option « Enable Incremental Linking »(8) est réglée sur « Oui ».

Cette option permet une édition de liens plus rapide (en apparence) mais introduit clairement un léger surcoût à tous les appels de fonction. C'est le genre de chose que vous pourriez peut-être envisager de désactiver dans les options de compilation - mais vous pourriez vouloir analyser les performances de ceci afin de prendre une décision éclairée quant à son impact avant de le faire.

L'instruction jmp ne perturbe pas la pile, donc aucun dégât (autre que le « loupé » susceptible d'être introduit par table de sauts de l'édition de liens incrémentiels dans le cache d'instructions).

XIII. Récupération du paramètre passé à AddOneTo()

Enfin, nous arrivons au code du corps de la fonction AddOneTo() :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
     1: int AddOneTo( int iParameter )
     2: {
01311250  push        ebp  
01311251  mov         ebp,esp  
01311253  sub         esp,44h  
01311256  push        ebx  
01311257  push        esi  
01311258  push        edi  
     3:     int iLocal = iParameter + 1;
01311259  mov         eax,dword ptr [ebp+8]  
0131125C  add         eax,1  
0131125F  mov         dword ptr [ebp-4],eax  
     4:     return iLocal;
01311262  mov         eax,dword ptr [ebp-4]  
     5: }
01311265  pop         edi  
01311266  pop         esi  
01311267  pop         ebx  
01311268  mov         esp,ebp  
0131126A  pop         ebp  
0131126B  ret

Nous sommes déjà familiers avec le préambule (lignes 3 à 8) et le postambule (lignes 16 à 20) qui sont identiques à ceux de la fonction main().

La ligne 10 est beaucoup plus intéressante car elle récupère le paramètre iParameter de fonction sur la Pile. Notez le décalage positif de ebp - ce qui signifie que l'adresse à laquelle elle récupère la valeur pour la mettre dans le registre eax est en dehors du « Stack Frame » de cette fonction.

Comme nous l'avons vu plus tôt quand nous avons sauté à l'adresse de cette fonction, l'adresse pointée par esp contient l'adresse de retour et une copie de la variable locale iResult est stockée à [esp+4].

La première instruction dans le préambule est une instruction push ce qui ajoute 4 autres octets à esp. Donc immédiatement après la ligne 3 la valeur de iResult ou iParameter comme il est référencé dans cette fonction - est maintenant à [esp+8].

L'instruction suivante recopie la valeur de esp dans ebp de sorte que la valeur de iReturn passée en paramètre à la fonction est désormais également à [ebp+8] - ce qui est exactement l'emplacement utilisé à la ligne 10.

Donc maintenant, nous savons comment les arguments sont passés aux fonctions. C'est gagné !

Comme il s'agit de l'instant où il y a le plus de données sur la Pile, nous devrions y jeter un coup d'oeil afin que nous puissions voir exactement tout cela :

Image non disponible
tat de la Pile juste après le préambule de la fonction AddOneTo()

XIV. Retourner le résultat de AddOneTo()

Si on ignore le préambule de la fonction, on se retrouve avec :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
     3:     int iLocal = iParameter + 1;
01311259  mov         eax,dword ptr [ebp+8]  
0131125C  add         eax,1  
0131125F  mov         dword ptr [ebp-4],eax  
     4:     return iLocal;
01311262  mov         eax,dword ptr [ebp-4]  
     5: }
01311265  pop         edi  
01311266  pop         esi  
01311267  pop         ebx  
01311268  mov         esp,ebp  
0131126A  pop         ebp  
0131126B  ret
  • la ligne 2 copie la valeur de iParamètre de la Pile dans eax ;
  • les lignes 3 et 4 ajoutent 1 dans eax puis recopient le contenu de eax dans l'adresse [ebp-4] qui est l'adresse de la variable locale iLocal ;
  • la ligne 6 met en place la valeur de retour de la fonction en recopiant la valeur de iLocal dans eax - qui est l'endroit spécifié par la convention d'appel stdcall où doivent aller les valeurs de retour. Si vous vous souvenez, le code de la fonction main() qui accède à la valeur de retour s'attend à ce qu'il soit dans eax.

Si vous êtes attentif, vous avez sans doute remarqué que les lignes 4 et 6 sont redondantes puisque la valeur de retour était déjà dans eax, après la ligne 3.

Vous verrez ce genre de chose en permanence lors de l'analyse de code non optimisé - mais ce n'est pas une mauvaise chose après tout, quand il n'est pas demandé d'optimiser, la tâche du compilateur est de faire exactement ce que fait votre code.

Maintenant, la dernière pièce du puzzle est le retour de la fonction.

Nous savons que le postambule remet la Pile dans l'état où elle se trouvait immédiatement avant préambule de la fonction exécutée, ainsi nous savons déjà à quoi ça ressemble - car nous avons un diagramme de celle-ci à T = 2 :

Image non disponible
État de la pile juste après l'appel à AddOneTo() dans main()

L'instruction ret finale sur la ligne 13 dans le code ci-dessus fait que l'adresse de retour actuellement stockée sur le dessus de la Pile par l'instruction call dans le main() est dépilée (en ajoutant 4 à esp) et reprend l'exécution à cette adresse - c'est-à-dire à l'instruction immédiatement après l'appel de main().

Ouf. Nous y voilà. Vous voyez, ce n'était pas si compliqué !

XV. Résumé

Dans cet article, je vous ai guidé dans le désassemblage d'un simple programme avec un seul appel de fonction avec un paramètre et une valeur de retour afin de montrer comment la Pile fonctionne.

L'échantillon de désassemblage que nous avons examiné utilise la convention d'appel x86 stdcall, bien que des détails du code généré pour gérer la Pile varient entre les différentes conventions d'appel, la façon dont la Pile fonctionne sur n'importe quelle autre machine ou avec toute autre convention d'appel devrait être très similaire à ce principe.

Si, par pur hasard, vous travaillez avec une plateforme qui utilise une variante de la CPU IBM Power PC lancez simplement l'une des démonstrations simples du SDK et suivez pas à pas le désassembleur d'une version debug. Alors que les mnemonics seront (très) différents, juste le fait de passer un peu de temps avec le manuel du matériel et vous devriez trouver que ça fait à peu près la même chose que le code x86 que nous avons examiné.

Vous êtes susceptible de trouver des variations significatives par rapport à l'assembleur x86 que nous avons examiné puisque votre plateforme fonctionne avec presque certitude en utilisant une convention d'appels de fonction différente - par exemple votre compilateur peut la plupart du temps passer des arguments par l'intermédiaire des registres et n'utiliser la Pile que quand une fonction nécessite un grand nombre de paramètres ou utilise un nombre d'arguments variables (par exemple, printf()).

Quoi qu'il en soit, un petit coup d'œil rapide dans les manuels du matériel et/ou une recherche ou deux dans les forums et groupes de discussion du fabricant du matériel ou du compilateur devraient vous aider à obtenir les détails de la convention d'appel utilisée et vous permettre de surmonter ce problème.

En plus de cela, la compréhension que vous avez maintenant sur les mécanismes de la Pile et l'organisation de ses données devrait vous permettre de bien comprendre pourquoi les accès hors limite à des tableaux déclarés en variables locales peuvent être si dangereux. C'est précisément pourquoi vous devez bien réfléchir avant de passer des pointeurs sur des variables locales…

XVI. La prochaine fois ?

Croyez-le ou non, nous n'en avons pas fini avec la Pile - nous avons encore beaucoup de choses à voir pour couvrir ce sujet !

Par exemple :

  • ce qu'il se passe lors du passage de plus d'un paramètre ;
  • plus de détails sur la façon dont les variables locales utilisent la mémoire de la Pile ;
  • comment le passage et le retour par valeur fonctionnent ;
  • comment certaines des conventions d'appel x86 fonctionnent - en particulier fastcall qui ressemble davantage aux conventions d'appel généralement utilisées par l'ABI (Application Binary Interface) pour les plates-formes.

Compte tenu de la longueur de cet article (et du temps qu'il a fallu pour l'écrire…), je vais probablement diviser tout cela en plusieurs autres articles.

XVII. Épilogue

En supposant que vous avez exécuté ce code dans VS2010 alors, si vous avez du temps libre et envie d'en savoir un peu plus, vous pourriez exécuter ce code en utilisant une configuration « release » avec le même point d'arrêt et examiner la fenêtre du désassemblage lorsque le point d'arrêt est atteint.

Je trouve que le code optimisé généré est assez intéressant.

Vous pouvez également trouver amusant de voir les changements que vous devriez faire dans votre code afin de forcer le code optimisé à maintenir l'appel de la fonction et les variables locales. J'ai dû le faire.

XVIII. Remerciements de l'auteur

Je voudrais remercier Bruce, Tiffany, Jonathan, Fabian et Darren pour leurs retours sur cet article. C'est nettement mieux ainsi.

XIX. Remerciements

Cet article est une traduction autorisée de « C / C++ Low Level Curriculum Part 3: The Stack » par Alex Darby.

Je tiens a remercier mitkl pour la relecture technique de cette traduction ainsi que FirePrawn et ClaudeLELOUP pour leur relecture orthographique.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   


Note de traduction : Le mot « Bjible » est la contraction des premières lettres du prénom de Bjarne Stroustrup et du mot « bible ».
Note de traduction : une structure de donnée en pile utilise le concept LIFO, « Last In, First Out ».
Note de traduction : « Wintel » est la contraction de « Windows » et « Intel ».
Note de traduction : cette option s'appelle « Vérifications de base à l'exécution » sur un Visual Studio français.
Note de traduction : cette option s'appelle « Atteindre le code machine » sur un Visual Studio français.
Note de traduction : cette option s'appelle « Afficher les noms de symboles » sur un Visual Studio français.
Note de traduction : cette option s'appelle « Réévaluer automatiquement » sur un Visual Studio français.
Note de traduction : cette option s'appelle « Activation des liens incrémentiels » sur un Visual Studio français.

  

Copyright © 2012 Alex Darby. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.