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…
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 :
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 :
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) :
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 :
*
((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 ?!?
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 :
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 :
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 :
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) :
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.