Programme d'étude sur le C++ bas niveau n° 5 : encore plus de Pile

Alex Darby

Traducteur

ram-0000

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 leurs programmes sont exécutés en pratique. Ce cinquième article donne encore plus de détails sur le fonctionnement de la Pile et s'intéresse aux conventions d'appel.

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

N'hésitez pas à commenter cet article ! Commentez

- Titre : Programme d'étude sur le C++ bas niveau n° 5 : encore plus de Pile

- Auteur : Alex Darby

- Traducteur : ram-0000

- Parution : 29 janvier 2013

- Mise à jour : 20 décembre 2014

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

Sommaire

- I. Introduction

- II. Précédemment sur #AltDevBlogADay ?

- III. Compiler et lancer le code de cet article

- IV. La convention d'appel « thiscall »

- V. Circulez, il n'y a rien à voir

- VI. fastcall (le dernier, c'est promis)

- VII. Conclusion

- VIII. Remerciements

I - Introduction

Bienvenue dans ce cinquième opus de la série d'articles que j'écris sur le C++ bas niveau. Il s'agit du troisième article concernant la Pile, les fondamentaux ont été couverts il y a deux articles, l'article précédent ainsi que cet article sont juste des compléments qui permettent d'éclairer la compréhension de l'utilisation de la Pile en environnement win32 x86 utilisant la convention d'appel stdcall. Ensuite, nous pourrons aborder d'autres aspects « bas niveau » du langage C/C++.

Les deux conventions d'appel (pour win32 et x86) que nous allons étudier sont la convention thiscall utilisée pour appeler des fonctions membres de classes non statiques et la convention fastcall qui utilise les registres plutôt que la Pile pour le passage des paramètres. Comme dans les précédents articles au sujet de la Pile, ce n'est pas tant les spécificités des conventions d'appel que nous allons étudier mais plutôt la manière dont la Pile et les registres sont utilisés pour transmettre des paramètres lors des appels de fonction.

II - Précédemment sur #AltDevBlogADay ?

Si vous avez manqué les articles précédents concernant le Programme d'étude sur le C++ bas niveau, voici les liens :

- Programme d'étude sur le C++ bas niveau ;

- Programme d'étude sur le C++ bas niveau n° 2 : les types de données ;

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

- Programme d'étude sur le C++ bas niveau n° 4 : plus de Pile.

De manière générale, il n'y a pas trop de connaissances requises mais cet article suppose tout de même que vous ayez lu les articles 3 et 4 concernant la Pile (ou que vous ayez une bonne connaissance de la façon dont fonctionne la Pile en assembleur simple x86 et dans ce cas, pourquoi lisez-vous cet article !?).

III - Compiler et lancer le code de cet article

Je suppose que vous êtes familier avec l'environnement de développement VS2010 et que vous savez écrire, lancer et déboguer un programme C++.

Comme pour les précédents articles de cette série, je vais utiliser un programme win32 console en utilisant l'assistant « new project » de VS2010 avec les options par défaut (la version express de VS2010 est suffisante).

Le seul changement que je fais est de désactiver l'option « Basic Runtime Checks » pour rendre le code assembleur généré plus facile à lire (et aussi plus rapide…). Regardez l'article précédent pour plus de détails à ce sujet.

Pour exécuter le code de cet article dans VS2010 ouvrez le fichier .cpp qui ne s'appelle pas stdafx.cpp et remplacer tout le code juste en dessous de la ligne #include "stdafx.h" par le code copié/collé ci-dessous.

Le désassembleur que nous examinerons est celui de la version debug qui génère un code win32 x86 simple et non optimisé.

IV - La convention d'appel « thiscall »

Comme vous devez le savoir, dans n'importe quelle fonction membre de classe non statique, il est possible d'accéder à un pointeur vers l'instance de la classe dans la fonction appelée avec le mot-clé C++ this.

La présence du pointeur this est souvent expliquée en disant que c'est le « zéroième paramètre invisible » de la fonction ce qui n'est pas nécessairement incorrect mais qui est le même genre de vérité que Obiwan Kenobi aurait pu dire s'il avait été un professeur d'informatique plutôt qu'un Chevalier Jedi retraité, c'est-à-dire « vrai, d'un certain point de vue ».

La convention d'appel thiscall est plus ou moins la même que la convention d'appel stdcall que nous avons déjà vue en détail dans les deux articles précédents (this->pPrevious->pPrevious, this->pPrevious). Bien que cela soit la convention d'appel par défaut dans VS2010 pour les fonctions membres non statiques, il existe des situations où le compilateur ne l'utilise pas (par exemple, si votre fonction utilise l'opérateur ellipse pour attendre un nombre variable d'arguments).

Comme nous l'avons déjà vu dans les deux articles précédents, la convention d'appel stdcall win32 x86 non optimisée passe ses paramètres sur la Pile. La convention d'appel thiscall doit évidemment passer le pointeur this aux fonctions membre mais plutôt que de la passer par la Pile, elle utilise un registre (ecx) pour le passer à la fonction appelée.

Le code ci-dessous démontre tout ceci…

1. class CSumOf

2. {

3. public:

4. int m_iSumOf;

6. void SumOf( int iParamOne, int iParamTwo )

7. {

8. m_iSumOf = iParamOne + iParamTwo;

9. }

10. };

12. int main( int argc, char** argv )

13. {

14. int iValOne        = 1;

15. int iValTwo        = 2;

16. CSumOf cMySumOf;

17. cMySumOf.SumOf( iValOne, iValTwo );

18. return 0;

19. }

Copiez/collez ce code dans VS2010 et posez un point d'arrêt sur la ligne :

cMySumOf.SumOf( iValOne, iValTwo );

Lancez la configuration debug. Quand le point d'arrêt est atteint, faites un clic droit et choisissez l'option « Go To Disassembly ». Vous devriez voir quelque chose qui ressemble à ceci (les adresses dans la colonne de gauche du désassembleur seront presque certainement différentes) :

Assurez-vous que les options dans le menu contextuel correspondent bien à celles indiquées dans cette capture d'écran ou votre désassemblage ne ressemblera pas au mien !

Le bloc de code qui nous intéresse pour illustrer la convention d'appel thiscall est montré ci-dessous :

1. 14:     int iValOne        = 1;

2. 00EE1259  mov         dword ptr [iValOne],1

3. 15:     int iValTwo        = 2;

4. 00EE1260  mov         dword ptr [iValTwo],2

5. 16:     CSumOf cMySumOf;

6. 17:     cMySumOf.SumOf( iValOne, iValTwo );

7. 00EE1267  mov         eax,dword ptr [iValTwo]

8. 00EE126A  push        eax

9. 00EE126B  mov         ecx,dword ptr [iValOne]

10. 00EE126E  push        ecx

11. 00EE126F  lea         ecx,[cMySumOf]

12. 00EE1272  call        CSumOf::SumOf (0EE112Ch)

L'assembleur impliqué par l'appel à CSumof::SumOf() commence à la ligne 7 et se termine à la ligne 12.

Les lignes 7 à 10 empilent les paramètres de la fonction sur la Pile dans l'ordre inverse de leur déclaration, exactement comme avec la convention d'appel stdcall que nous avons vue dans l'article précédent.

La ligne 11 copie l'adresse de cMySumOf dans ecx en utilisant l'instruction lea. Si vous faites un clic droit et que vous décochez l'option « Show Symbol Names », vous pouvez voir que lea calcule l'adresse de cMySumOf en donnant son décalage par rapport au registre ebx.

La ligne 12 est évidemment l'appel de la fonction.

En entrant dans le code assembleur de la fonction, vous devriez voir ce qui suit (ne pas oublier que nous devons exécuter une instruction jmp supplémentaire avant d'en arriver là, à cause de l'édition de liens incrémentielle de VS2010 - voir à environ la moitié de cet article pour plus de détails) :

1. 6:     void SumOf( int iParamOne, int iParamTwo )

2. 7:     {

3. 00EE1280  push        ebp

4. 00EE1281  mov         ebp,esp

5. 00EE1283  sub         esp,44h

6. 00EE1286  push        ebx

7. 00EE1287  push        esi

8. 00EE1288  push        edi

9. 00EE1289  mov         dword ptr [ebp-4],ecx

10. 8:         m_iSumOf = iParamOne + iParamTwo;

11. 00EE128C  mov         eax,dword ptr [iParamOne]

12. 00EE128F  add         eax,dword ptr [iParamTwo]

13. 00EE1292  mov         ecx,dword ptr [this]

14. 00EE1295  mov         dword ptr [ecx],eax

15. 9:     }

Le code appelant recopie l'adresse de la variable locale cMySumOf dans le registre ecx avant d'appeler cette fonction et, si nous examinons la ligne 9 dans le code ci-dessus, vous pouvez voir que, par rapport à l'assembleur stdcall, le prologue de la fonction a une étape supplémentaire, il recopie la valeur de ecx dans une adresse mémoire dans le « Stack Frame » de la Pile de la fonction (c'est-à-dire ebp-4). Le résultat de ceci est que, après la ligne 9, [ebp-4] contient maintenant le pointeur this de la fonction.

La fonction continue alors, exactement comme on pouvait s'y attendre avec le code que nous avons examiné dans les articles précédents, jusqu'à la ligne 13.

La ligne 13 recopie le pointeur this (précédemment stockée dans le « Stack Frame » de la Pile de la fonction) dans ecx, puis la ligne 14 copie la valeur de eax dans l'adresse spécifiée par ecx (rappelez-vous, dans l'assembleur VS2010, les valeurs entre [crochets] sont des accès mémoire, l'adresse mémoire utilisée se calcule avec la valeur à l'intérieur des [crochets]). Si vous faites un clic droit dans la fenêtre de désassemblage et décochez l'option « Show Symbol Names », vous verrez que le symbole this correspond à ebp-4 ce qui est l'endroit où la valeur de ecx a été stockée à la fin du prologue de la fonction.

Ceux qui suivent auront remarqué que l'assembleur recopie le pointeur this de ecx vers la Pile uniquement pour pouvoir le recharger ultérieurement dans ecx sans avoir utilisé ce registre dans l'intervalle. C'est exactement le genre de chose étrange que fait un compilateur quand il génère du code non optimisé, essayez de ne pas vous laisser perturber par ceci.

Ainsi, la somme des deux paramètres est stockée en utilisant le pointeur this, puis nous avons atteint l'épilogue de la fonction et la fonction retourne. Fin de l'histoire ? Pas si sûr !

V - Circulez, il n'y a rien à voir

Ce n'est pas ce qu'on pourrait croire, car, d'après ce que nous avons vu jusqu'à présent, ce code qui initialise CSumOf::m_iSumOf dans la fonction membre ne correspond pas avec le code C++ que nous avons écrit.

Ce que nous voyons ressemble à ce qui pourrait être généré avec le code :

*((int*) this) = iParamOne + iParamTwo;

Et en fait, si vous remplacez cette ligne, il va générer exactement le même assembleur. Alors, comment ça marche ?!?

1. // Voici ce que nous avons écrit. Comme m_iSumOf est membre de la classe, la syntaxe du

2. // langage permet "d'y accéder directement" (un autre Kenobisme) dans la fonction membre

3. m_iSumOf = iParamOne + iParamTwo;

5. // En fait, le compilateur évalue le code comme s'il était écrit comme ceci

6. this->m_iSumOf = iParamOne + iParamTwo;

OK, donc il y a un accès pointeur invisible dans le code C++, mais cela n'explique toujours pas ce que nous voyons. Comment le code « *((int*) this) » peut-il être équivalent à « this->m_iSumOf ».

La réponse est en rapport avec l'agencement en mémoire de classes C++ (et des structures) ce qui est un sujet pour un autre article entier (probablement plusieurs).

Pour l'instant, nous allons nous contenter d'une explication simple, tout en essayant de ne pas suivre notre ami le professeur Kenobi plus que nécessaire.

D'abord nous allons considérer comme acquis que les données membres d'une instance de classe sont stockées dans la mémoire et prendre un peu d'altitude pour voir comment l'opérateur « pointe vers » (->) fonctionne avec un bout de code :

this->m_iSumOf = 0;

Essentiellement, cela indique au compilateur de générer un code qui :

- obtient la valeur de this (une adresse mémoire) ;

- recherche le décalage de m_iSumOf par rapport au début des données qui constituent l'instance de CSumOf (qui est connu au moment de la compilation, c'est donc une constante au moment de l'exécution) ;

- ajoute ce décalage à l'adresse de this pour obtenir l'adresse mémoire stockant m_iSumOf puis initialiser la valeur de l'adresse mémoire résultante à 0.

Le pointeur this contient l'adresse du premier octet de données dans une instance de CSumOf.

La première (et unique) variable membre dans CSumOf est m_iSumOf, ce qui fait que son décalage est 0 par rapport à this, et clairement même une version debug sait très bien résoudre ce problème que d'ajouter un décalage de 0, alors il accède directement la mémoire pointée par this.

Donc, encore une fois, nous pouvons voir que même avec un code C++ en apparence anodin, il se passe des trucs cachés, ce qui est une grande partie de la raison pour laquelle j'écris ces articles. :)

Par ailleurs, j'ai appris récemment une caractéristique incroyablement utile (et non documentée !) du compilateur VS2010. Il affiche l'agencement mémoire des classes lors de la compilation dans un fichier de sortie. Voici le lien que j'ai utilisé, j'espère qu'il vous sera utile : /d1reportAllClassLayout - Dumping Object Memory Layout

VI - fastcall (le dernier, c'est promis)

Enfin nous arrivons à la convention d'appel x86 win32 nommée fastcall, ainsi nommée parce que, en théorie, elle produit des appels de fonction plus rapides (que les conventions d'appel stdcall et cdecl).

Alors, pourquoi est-elle plus rapide que les autres conventions appel que nous avons regardées ? Pour répondre à cela, nous allons examiner l'assembleur généré par un appel de fonction qui utilise la convention fastcall.

Pour voir cela, nous allons utiliser le code ci-dessous :

1. int __fastcall SumOf( int iParamOne, int iParamTwo, int iParamThree )

2. {

3. int iLocal = iParamOne + iParamTwo + iParamThree;

4. return iLocal;

5. }

7. int main( int argc, char** argv )

8. {

9. int iValOne   = 1;

10. int iValTwo   = 2;

11. int iValThree = 4;

12. int iResult   = SumOf( iValOne, iValTwo, iValThree );

13. return 0;

14. }

Ceci est essentiellement le même que le code utilisé dans l'article précédent de cette série pour montrer comment la convention d'appel stdcall copie les paramètres sur la Pile sauf que la fonction SumOf a un mot-clé supplémentaire entre le type de retour et le nom de la fonction.

Le mot-clé __fastcall est une extension Microsoft pas tout à fait spécifique du C++ qui modifie la convention d'appel utilisée pour cette fonction (Wikipedia - x86 calling conventions - fastcall).

Si vous faites l'exercice habituel pour créer un projet exécutable à partir de ce code, mettez un point d'arrêt à la ligne 12, compilez, exécutez la version de débogage, attendez que le point d'arrêt soit atteint, et allez au désassemblage, vous devriez voir quelque chose comme ceci :

1. 8: int main( int argc, char** argv )

2. 9: {

3. 010F1280  push        ebp

4. 010F1281  mov         ebp,esp

5. 010F1283  sub         esp,50h

6. 010F1286  push        ebx

7. 010F1287  push        esi

8. 010F1288  push        edi

9. 10:     int iValOne   = 1;

10. 010F1289  mov         dword ptr [iValOne],1

11. 11:     int iValTwo   = 2;

12. 010F1290  mov         dword ptr [iValTwo],2

13. 12:     int iValThree = 4;

14. 010F1297  mov         dword ptr [iValThree],4

15. 13:     int iResult   = SumOf( iValOne, iValTwo, iValThree );

16. 010F129E  mov         eax,dword ptr [iValThree]

17. 010F12A1  push        eax

18. 010F12A2  mov         edx,dword ptr [iValTwo]

19. 010F12A5  mov         ecx,dword ptr [iValOne]

20. 010F12A8  call        SumOf (10F1136h)

21. 010F12AD  mov         dword ptr [iResult],eax

22. 14:     return 0;

23. 010F12B0  xor         eax,eax

24. 15: }

Vous devriez, à ce stade, être assez familier avec les prologues de fonction et de l'assembleur qui précède un appel de fonction dans les autres conventions d'appel que nous avons examinées, nous allons donc regarder les différences avec __fastcall.

En regardant les lignes 16 à 20, on peut voir que les trois paramètres passés à SumOf():

- le troisième (iValThree) est empilé sur la Pile ;

- le second (iValTwo) est copié dans le registre edx ;

- le premier (iValOne) est copié dans le registre ecx.

En entrant dans le code de SumOf(), vous devriez voir quelque chose comme ceci (j'ai désactivé l'option « Show Symbol Names » avant de récupérer ce code désassemblé de sorte que les adresses soient toutes visibles) :

1. 2: int __fastcall SumOf( int iParamOne, int iParamTwo, int iParamThree )

2. 3: {

3. 010F1250  push        ebp

4. 010F1251  mov         ebp,esp

5. 010F1253  sub         esp,4Ch

6. 010F1256  push        ebx

7. 010F1257  push        esi

8. 010F1258  push        edi

9. 010F1259  mov         dword ptr [ebp-8],edx

10. 010F125C  mov         dword ptr [ebp-4],ecx

11. 4:     int iLocal = iParamOne + iParamTwo + iParamThree;

12. 010F125F  mov         eax,dword ptr [ebp-4]

13. 010F1262  add         eax,dword ptr [ebp-8]

14. 010F1265  add         eax,dword ptr [ebp+8]

15. 010F1268  mov         dword ptr [ebp-0Ch],eax

16. 5:     return iLocal;

17. 010F126B  mov         eax,dword ptr [ebp-0Ch]

18. 6: }

L'assembleur constituant le prologue de la fonction effectue un travail supplémentaire par rapport à une fonction stdcall, il récupère les valeurs de ecx et edx et les recopie dans le « Stack Frame » de la Pile de la fonction (lignes 9 et 10).

Les lignes 12 à 14 vont alors ajouter les trois valeurs qui lui sont transmises en utilisant eax, iParamOne (transmis par ecx et maintenant dans [ebp-4]), iParamTwo (transmis par edx et maintenant dans [ebp-8]) et iParamThree (transmis par la Pile dans [ebp +8]).

La ligne 15 initialise iLocal avec la somme calculée dans eax puis, la ligne 16 recopie la valeur de retour de la fonction dans eax, là où le code appelant s'attend à le trouver (comme précédemment décrit dans cet article).

C'est bien beau tout cela, mais pourquoi fastcall est-il plus rapide que les autres conventions d'appel ?

En théorie, passer les arguments par les registres devrait éviter deux opérations par paramètre :

- ne pas écrire la valeur sur la Pile (à savoir, un accès à la mémoire) avant que la fonction ne soit appelée ;

- ne pas relire la valeur sur la Pile (à savoir, un autre accès à la mémoire) lorsque cela est nécessaire à l'intérieur de la fonction.

En règle générale, exécuter moins d'opérations et éviter celles qui nécessitent des accès à la mémoire, devrait se traduire par un code plus rapide mais ce n'est pas toujours le cas. Je ne veux pas entrer dans la discussion de pourquoi c'est ainsi, parce qu'en lui-même ce sujet mériterait de nombreux articles par des gens plus qualifiés que moi (par exemple, Bruce Dawson, Mike Acton, Tony Albrecht, Jaymin Kessler ou John McCutchan).

En toute honnêteté, je serais extrêmement surpris si le code non optimisé que nous avons examiné fonctionnait plus vite lors de l'utilisation fastcall. Comme vous pouvez le voir en examinant le désassembleur ci-dessus, la première de ces opérations potentiellement optimisées est annulée en empilant les contenus de ecx et edx sur la Pile dans le prologue de la fonction et la seconde est aussi annulée en accédant aux valeurs des paramètres à partir de la Pile dans les lignes 12 et 13.

Je suppose que, pour toutes les instances redondantes de code assembleur non optimisé que nous avons vues, ces instructions inutiles seraient heureusement optimisées dans une version release, mais la triste réalité est qu'il est assez difficile de tester le désassembleur des programmes triviaux comme ceux que nous avons examinés de façon significative dans une configuration release.

Pourquoi ? Parce que le compilateur d'optimisation est si bon que n'importe quel programme simple (comme celui-ci) qui utilise des constantes de compilation en entrée et ne génère pas de sortie pourrait ressembler à peu près à « return 0; »

Je laisse ceci comme un exercice, pour vous cher lecteur, de travailler sur le plus petit nombre de modifications de ce code qui se traduira par un désassembleur qui appelle en fait SumOf().

VII - Conclusion

Ainsi, nous avons vu comment thiscall et fastcall diffèrent des autres conventions d'appel x86 que nous avions déjà examinées et nous avons vu une fois de plus que, même dans un code simple, il y a une magie noire qui se passe derrière les scènes de la syntaxe du langage.

Je tiens aussi à souligner que, même si sur des plateformes non x86 les choses seront faites un peu différemment, cette information est généralement plus utile que cela ne peut le paraître. Au plus vous verrez de manières différentes en assembleur d'accomplir des tâches similaires (comme appeler une fonction avec la Pile), au plus vous aurez de chances de comprendre un assembleur que vous n'aurez jamais vu au préalable (par exemple, l'assembleur PowerPC). Bien sûr, les mnémoniques peuvent être très différentes mais vous devriez être capable de deviner un grand nombre d'entre eux et la documentation est là pour vous permettre de recoller les morceaux.

Nul doute que nous reviendrons sur la Pile de temps en temps dans cette série d'articles (potentiellement sans fin ! Help !) mais je l'ai maintenant décrite avec suffisamment de détails pour permettre de couvrir ces autres aspects du « Programme d'étude sur le C++ bas niveau ». Par exemple, nous allons certainement revenir à la Pile lorsque l'on examinera les structures et les classes ainsi que leur disposition en mémoire pour discuter du passage par valeur.

La prochaine fois, nous allons examiner le désassembleur de constructions basiques du C++ comme les boucles et les instructions de contrôle qui sont des choses très utiles à connaitre si vous vous retrouvez face à un bout de désassembleur issu d'un crash dans le code et dont vous n'avez pas la table des symboles.

Dans le cas où vous l'auriez oublié, voici à nouveau le lien concernant la fonction non documentée du compilateur VS2010 qui génère l'agencement mémoire des classes à l'issue de la génération : /d1reportAllClassLayout - Dumping Object Memory Layout.

Au fait, merci à Fabian et Bruce pour leur relecture de cet article.

VIII - Remerciements

Cet article est la traduction de l'article « C / C++ Low Level Curriculum Part 5: Even More Stack » écrit en anglais par Alex Darby. Alex Darby a aimablement autorisé l'équipe C/C++ de Developpez.com à traduire et diffuser son article en français.

Je tiens à remercier mitkl pour la relecture technique ainsi que ClaudeLeloup pour la relecture orthographique de cette traduction.