Programme d'étude sur le C++ bas niveau n° 5 : encore plus de 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 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 ! 10 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 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 :

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…

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
class CSumOf
{
public:
    int m_iSumOf;
 
    void SumOf( int iParamOne, int iParamTwo )
    {
        m_iSumOf = iParamOne + iParamTwo;
    }
};
 
int main( int argc, char** argv )
{
    int iValOne        = 1;
    int iValTwo        = 2;
    CSumOf cMySumOf;
    cMySumOf.SumOf( iValOne, iValTwo );
    return 0;
}

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

 
Sélectionnez
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) :

Image non disponible

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
    14:     int iValOne        = 1;
00EE1259  mov         dword ptr [iValOne],1
    15:     int iValTwo        = 2;
00EE1260  mov         dword ptr [iValTwo],2
    16:     CSumOf cMySumOf;
    17:     cMySumOf.SumOf( iValOne, iValTwo );
00EE1267  mov         eax,dword ptr [iValTwo]
00EE126A  push        eax
00EE126B  mov         ecx,dword ptr [iValOne]
00EE126E  push        ecx
00EE126F  lea         ecx,[cMySumOf]
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) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
     6:     void SumOf( int iParamOne, int iParamTwo )
     7:     {
00EE1280  push        ebp
00EE1281  mov         ebp,esp
00EE1283  sub         esp,44h
00EE1286  push        ebx
00EE1287  push        esi
00EE1288  push        edi
00EE1289  mov         dword ptr [ebp-4],ecx
     8:         m_iSumOf = iParamOne + iParamTwo;
00EE128C  mov         eax,dword ptr [iParamOne]
00EE128F  add         eax,dword ptr [iParamTwo]
00EE1292  mov         ecx,dword ptr [this]
00EE1295  mov         dword ptr [ecx],eax
     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 :

 
Sélectionnez
*((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 ?!?

 
Sélectionnez
1.
2.
3.
4.
5.
6.
// Voici ce que nous avons écrit. Comme m_iSumOf est membre de la classe, la syntaxe du
// langage permet "d'y accéder directement" (un autre Kenobisme) dans la fonction membre
m_iSumOf = iParamOne + iParamTwo;
 
// En fait, le compilateur évalue le code comme s'il était écrit comme ceci
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 :

 
Sélectionnez
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
int __fastcall SumOf( int iParamOne, int iParamTwo, int iParamThree )
{
    int iLocal = iParamOne + iParamTwo + iParamThree;
    return iLocal;
}
 
int main( int argc, char** argv )
{
    int iValOne   = 1;
    int iValTwo   = 2;
    int iValThree = 4;
    int iResult   = SumOf( iValOne, iValTwo, iValThree );
    return 0;
}

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
     8: int main( int argc, char** argv )
     9: {
010F1280  push        ebp
010F1281  mov         ebp,esp
010F1283  sub         esp,50h
010F1286  push        ebx
010F1287  push        esi
010F1288  push        edi
    10:     int iValOne   = 1;
010F1289  mov         dword ptr [iValOne],1
    11:     int iValTwo   = 2;
010F1290  mov         dword ptr [iValTwo],2
    12:     int iValThree = 4;
010F1297  mov         dword ptr [iValThree],4
    13:     int iResult   = SumOf( iValOne, iValTwo, iValThree );
010F129E  mov         eax,dword ptr [iValThree]
010F12A1  push        eax
010F12A2  mov         edx,dword ptr [iValTwo]
010F12A5  mov         ecx,dword ptr [iValOne]
010F12A8  call        SumOf (10F1136h)
010F12AD  mov         dword ptr [iResult],eax
    14:     return 0;
010F12B0  xor         eax,eax
    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) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
     2: int __fastcall SumOf( int iParamOne, int iParamTwo, int iParamThree )
     3: {
010F1250  push        ebp
010F1251  mov         ebp,esp
010F1253  sub         esp,4Ch
010F1256  push        ebx
010F1257  push        esi
010F1258  push        edi
010F1259  mov         dword ptr [ebp-8],edx
010F125C  mov         dword ptr [ebp-4],ecx
     4:     int iLocal = iParamOne + iParamTwo + iParamThree;
010F125F  mov         eax,dword ptr [ebp-4]
010F1262  add         eax,dword ptr [ebp-8]
010F1265  add         eax,dword ptr [ebp+8]
010F1268  mov         dword ptr [ebp-0Ch],eax
     5:     return iLocal;
010F126B  mov         eax,dword ptr [ebp-0Ch]
     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.

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

  

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.