I. Introduction▲
Bienvenue dans ce quatrième article « Programme d'étude sur le C++ bas niveau, plus de Pile » !
L'article précédent était un mammouth et il m'a pris beaucoup de temps donc, cet article sera beaucoup plus court et, par conséquent, couvrira moins de choses. Nous allons voir plus spécifiquement ce qui arrive lorsque l'on passe plus d'un paramètre avec un code en assembleur x86 non optimisé utilisant la convention d'appel stdcall.
Cet article suppose que vous avez lu le précédent article au sujet de la Pile ou savez déjà comment fonctionne la Pile avec un assembleur simple x86.
Si vous avez manqué ces articles, 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.
J'ai également trouvé un excellent article sur l'ABI IBM PowerPC qui explique en détail (et au niveau assembleur) comment la Pile est utilisée avec les processeurs basés sur le PowerPC car c'est plus différent du x86 que je ne l'imaginais. Vous pouvez trouver cet article utile si vous travaillez sur les consoles de la génération actuelle et désirez comprendre comment ils utilisent la Pile, les appels de fonctions et le passage de paramètres.
II. Plus d'un paramètre à une fonction▲
Comme précédemment, j'utiliserai une application win32 en mode console construite avec l'option « new project » dans VS2010 avec les options par défaut. Le désassembleur que nous examinerons est généré par la configuration « debug », ce qui génère du code assembleur x86 non optimisé et simple utilisant la convention d'appel stdcall.
Le seul changement apporté est la désactivation de l'option « Basic Runtime Checks » pour générer un assembleur plus lisible (et nettement plus rapide…). Regardez l'article précédent pour voir comment faire.
Nous allons modifier le programme très simple de l'article précédent de manière à ce que la fonction appelée utilise trois paramètres.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
int
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
;
}
Et voici le code assembleur généré pour la fonction main() (comme précédemment, les adresses des instructions sont très probablement différentes pour vous) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
7
: int
main( int
argc, char**
argv )
8
: {
00401280
push
ebp
00401281
mov
ebp
,esp
00401283
sub
esp
,50h
00401286
push
ebx
00401287
push
esi
00401288
push
edi
9
: int
iValOne =
1
;
00401289
mov
dword
ptr
[ebp
-
4
],1
10
: int
iValTwo =
2
;
00401290
mov
dword
ptr
[ebp
-
8
],2
11
: int
iValThree =
4
;
00401297
mov
dword
ptr
[ebp
-
0Ch
],4
12
: int
iResult =
SumOf( iValOne, iValTwo, iValThree );
0040129E
mov
eax
,dword
ptr
[ebp
-
0Ch
]
004012A1
push
eax
004012A2
mov
ecx
,dword
ptr
[ebp
-
8
]
004012A5
push
ecx
004012A6
mov
edx
,dword
ptr
[ebp
-
4
]
004012A9
push
edx
004012AA
call
00401127
004012AF
add
esp
,0Ch
004012B2
mov
dword
ptr
[ebp
-
10h
],eax
13
: return 0
;
004012B5
xor
eax
,eax
14
: }
004012B7
pop
edi
004012B8
pop
esi
004012B9
pop
ebx
004012BA
mov
esp
,ebp
004012BC
pop
ebp
004012BD
ret
III. L'appel de SumOf()▲
Comme nous avons vu dans l'article précédent, nous pouvons maintenant ignorer sans risque le préambule et le postambule (respectivement, le prologue et l'épilogue) de la fonction (lignes 3 à 8 et 28 à 31) qui créent et détruisent le « Stack Frame » car nous savons maintenant qu'ils ne sont pas impliqués dans le passage des paramètres à SumOf().
Un rapide coup d'œil au code assembleur qui initialise les variables locales nous indique que iValOne, iValTwo et iValThree sont respectivement stockés à l'emplacement [ebp
-
4
], [ebp
-
8
] et [ebp
-
0Ch
].
Le code assembleur responsable de l'appel de la fonction et de la récupération de la valeur du retour est celui-ci :
16.
17.
18.
19.
20.
21.
22.
23.
24.
12
: int
iResult =
SumOf( iValOne, iValTwo, iValThree );
0040129E
mov
eax
,dword
ptr
[ebp
-
0Ch
]
004012A1
push
eax
004012A2
mov
ecx
,dword
ptr
[ebp
-
8
]
004012A5
push
ecx
004012A6
mov
edx
,dword
ptr
[ebp
-
4
]
004012A9
push
edx
004012AA
call
00401127
004012AF
add
esp
,0Ch
004012B2
mov
dword
ptr
[ebp
-
10h
],eax
Comme avec un seul argument, les copies des paramètres de la fonction sont empilées (push
) sur la Pile - notez bien qu'ils sont empilés dans l'ordre inverse par rapport à la liste des paramètres attendus dans le code C++.
La dernière chose à noter, c'est que juste après l'instruction call de la ligne 22 (c'est-à-dire immédiatement avant que l'assembleur pour SumOf () ne soit exécuté), la copie de iValOne qui a été empilée sur la Pile à la ligne 21 est maintenant à [esp
+
4
] parce que l'instruction call empile aussi l'adresse de retour de la fonction sur la Pile.
Juste au cas où, voici à quoi ressemble la Pile immédiatement après que la ligne 22 a été exécutée mais avant que tout code de la fonction SumOf() ne soit exécuté :
IV. Accéder aux paramètres▲
Voici le code assembleur de la fonction SumOf() :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
1
: int
SumOf( int
iParamOne, int
iParamTwo, int
iParamThree )
2
: {
00401250
push
ebp
00401251
mov
ebp
,esp
00401253
sub
esp
,44h
00401256
push
ebx
00401257
push
esi
00401258
push
edi
3
: int
iLocal =
iParamOne +
iParamTwo +
iParamThree;
00401259
mov
eax
,dword
ptr
[ebp
+
8
]
0040125C
add
eax
,dword
ptr
[ebp
+
0Ch
]
0040125F
add
eax
,dword
ptr
[ebp
+
10h
]
00401262
mov
dword
ptr
[ebp
-
4
],eax
4
: return iLocal;
00401265
mov
eax
,dword
ptr
[ebp
-
4
]
5
: }
00401268
pop
edi
00401269
pop
esi
0040126A
pop
ebx
0040126B
mov
esp
,ebp
0040126D
pop
ebp
0040126E
ret
Nous pouvons voir que le prologue de la fonction empile ebp
, ce qui décale esp
de quatre octets supplémentaires, puis copie esp
dans ebp
- si bien qu'à la ligne 4, la valeur de iValOne est maintenant à [ebp
+
8
].
Voici le nouvel état de la Pile juste après le prologue de la fonction (c'est-à-dire après la ligne 8) :
En regardant les lignes 10 à 12, nous pouvons voir que le code accède aux paramètres comme ceci :
- iParamOne (iValOne) à partir de [
ebp
+
8
] ; - iParamTwo (iValTwo) à partir de [
ebp
+
0Ch
] ; - iParamThree (iValThree) à partir de [
ebp
+
10h
].
Ce qui, sans surprise, est exactement là où la fonction main() avait empilé les valeurs sur la Pile juste après le prologue de la fonction main().
Maintenant, nous comprenons pourquoi les paramètres de la fonction sont empilés par la fonction main() sur la Pile dans l'ordre inverse - parce que les fonctions appelées s'attendent à trouver ces paramètres dans le bon ordre à partir de [ebp
+
8
] en incrémentant le décalage à partir de ebp
pour chacun des paramètres.
Comme auparavant, la valeur de retour (iLocal, stockée à [ebp
-
4
]) est copiée dans eax
avant le code de l'épilogue de la fonction de manière à le retourner à la fonction main() et comme nous savons comment l'épilogue et le retour fonctionnent depuis l'article précédent nous savons tout sur les appels stdcall avec plusieurs paramètres. Quelle joie !
V. En résumé▲
Nous avons vu en détail comment est utilisée la Pile pour appeler des fonctions stdcall simples et non optimisées par le compilateur en assembleur x86. Cela devrait vous suffire pour aller trainer dans la fenêtre de désassemblage avec une assez bonne idée de quelles parties du code assembleur de chaque fonction sont le plus susceptibles d'être pertinentes.
Pour plus d'information et pour vous montrer les différences d'utilisation de la Pile (bien que basiquement, cela soit le même principe), voici un lien sur la quatrième partie d'une série d'articles du site IBM qui traite de l'assembleur du PowerPC et en particulier l'ABI 64bits : Assembly language for Power Architecture, Part 4: Function calls and the PowerPC 64-bit ABI.
Selon toute vraisemblance, vous aurez besoin de lire les trois premiers articles pour donner un sens au quatrième, mais ce quatrième article est l'endroit où se trouvent la plupart des informations juteuses.
VI. La prochaine fois▲
La prochaine fois, je vais examiner la convention d'appel thiscall pour x86 utilisée par les fonctions membres C++ (quand le pointeur this
est passé dans ecx
) et nous regarderons aussi comment la convention d'appel fastcall utilise la Pile, son nom semble excitant.
Au fait, Joyeux Noël !
VII. Remerciements▲
Cet article est une traduction autorisée de « C / C++ Low Level Curriculum Part 4: More Stack » par Alex Darby.
Je tiens à remercier ClaudeLELOUP pour la relecture orthographique de cette traduction.