À l'origine, ce texte a été construit à l'aide de Visual Studio 6, un vieil outil, sur une machine 32 bits. Ainsi, vous y trouverez des affirmations quant à la taille de certains types, affirmations qui peuvent ne pas s'avérer sur votre ordinateur ou avec votre compilateur. Supposez donc, pour les fins de la discussion :
Évidemment, sizeof(char)==1 sur toutes les plateformes.
Le code machine proposé en exemple dans ce qui suit provient d'un compilateur qu'on pourrait qualifier d'ancien (le texte a été écrit au début des années 2000). L'objectif visé ici est d'illustrer le lien entre le code source et le code généré, sans plus.
Notre portrait de ce qu'est la mécanique de compilation, d'édition des liens et d'exécution d'un programme se clarifie. Le dernier gros morceau du casse-tête que nous couvrirons en ce sens dans le cours est d'expliquer ce qui se produit lors d'un appel de sous-programme, ce que représente la mécanique de passage de paramètres en C++, et ce qui se produit lors de la conclusion d'une fonction.
Pour être en mesure d'expliquer ces dernières considérations de manière compréhensible, il importe d'abord de faire un retour sur l'ensemble de la mécanique d'exécution d'un programme, pour voir où s'introduisent les problèmes propres aux sous-programmes, et pour comprendre les outils utilisés (souvent à notre insu) pour les régler.
La compilation d'un module source (en C++, les sources portent généralement l'extension .cpp) résulte en un module objet, ou .obj, et l'édition des liens assure la jonction de ces modules objet en un exécutable, ou .exe.
On peut voir l'exécution d'un programme sans sous-programmes comme une suite d'instructions en code machine (ou en langage d'assemblage) contiguës en mémoire, parcourues une à une consécutivement, sauf dans les cas où des instructions de branchement (ou de saut) sont rencontrées. Le code à droite montre les instructions C++, suivi du code assembleur généré par le compilateur VC6.0. |
|
Si un programme ne se composait que d'une suite linéaire d'instructions, la programmation deviendrait fort ardue. Heureusement, nous avons des outils qui nous permettent de faire des choix en fonction de conditions précises, et de répéter des séquences d'instructions sous certaines conditions.
Les instructions de branchement (requises pour les alternatives et les répétitives) introduisent un bris dans la séquence, et sont prises (ou non) en fonction du résultat d'une comparaison.
En assembleur x86, l'instruction de comparaison en langage d'assemblage se nomme CMP, pour Compare. Lorsqu'une instruction de saut conditionnel[1] est rencontrée, la séquence de code pourra être brisée, et la prochaine instruction pourra être ailleurs dans le code du programme (à l'endroit dicté par la logique du programme, en fait). Lorsqu'on rencontrera un saut inconditionnel JMP, le séquence de code sera brisée. |
|
Le registre IP contient l'adresse de la prochaine instruction machine à traiter. Un instruction de saut dans le programme, lorsqu'elle est suivie, force une valeur spécifique dans ce registre.
Pour être exact, IP ne contient pas l'adresse de la prochaine instruction à exécuter, mais bien la distance (on dit souvent offset) en octets depuis le début du segment de code (CS).
La première instruction d'un programme est à CS+0 (on écrira CS:0); la seconde sera à CS+[taille 1re], la troisième sera à CS+[taille 1re+taille 2e], etc.
On trouve normalement des adresses relatives à la fonction main() (exemple : main+55h au lieu de quelque chose comme 004012a5) dans les exemples de code assembleur que nous avons vu jusqu'ici, plutôt que des adresses absolues.
La raison est que tant que le code exécutable n'est pas chargé en mémoire, à un endroit précis, il est impossible de savoir précisément quelles les adresses réelles des différentes instructions du programme. Ainsi :
Par exemple :
Avant le chargement | Après le chargement |
---|---|
|
|
main+55h est une adresse relative à main. Si on ne sait pas où est main, alors on ne sait pas ce que signifie main+55h. |
004012a5 est une adresse absolue, donc qu'on connaît avec précision. |
Une répétitive, comme une alternative, impliquera au niveau du code machine des instructions de comparaison et de branchement. Il faut toutefois être prudent en lisant du code généré par un compilateur. La logique sera respectée, mais la manière peut être surprenante (une condition de poursuite en C++ sera, si elle se trouve au début d'une répétitive, inversée pour générer une condition de sortie en assembleur). |
|
Lorsqu'on introduira des sous-programmes dans le modèle de programmation, toutefois, se produiront des situations que nous n'avons pas encore étudié...
Nous savons que les instructions d'un programme se retrouvent dans un espace mémoire qu'on nommera son segment de code (Code Segment). Ses données globales se retrouveront à un endroit qu'on nommera son segment de données (Data Segment)
En plus, chaque processus[2] se voit attribué un espace pour sa pile d'exécution. Vous avez peut-être déjà vu ce qu'est la structure de données qu'on nomme pile; la pile d'exécution d'un processus en est un cas très concret.
Nous parlerons ici de fonction appelante, soit celle où un appel de fonction est fait, et de fonction appelée, soit celle qui est l'objet de l'appel. Souvent, on parlera simplement d'appelant et d'appelé.
Supposons une procédure main() qui appelle un sous-programme[3] f1() . L'exécution du programme suivra les étapes suivantes :
|
![]() |
Cela paraît à première vue simple et naturel. La solution naïve serait de faire, en langage d'assemblage, à peu près comme suit :
main: instruction1
instruction2
jmp [f1] ; aller au début du sous-programme f1
après: instruction3 ; après f1
instruction4
fin: int 20 ; fin du programme
f1: instr_f11 ; début de f1
instr_f12
instr_f13
jmp après: ; revenir au lieu de l'appel
Mais cette solution ne fonctionne que si f1() ne peut être appelée que du programme principal, puisqu'une fois f1() terminé, on retourne nécessairement à l'endroit que nous avons ici identifié comme après:.
Dans la vraie vie, l'appel d'un sous-programme donné peut se faire à plusieurs endroits dans un programme. La solution naïve est donc insuffisante pour traiter les cas réels.
Il ne suffit donc pas de sauter ailleurs en mémoire pour aller d'une fonction appelante à une fonction appelée? Par exemple : comment le code sait-il à quelle « ligne » de l'appelant revenir lorsque se termine la fonction appelée?
L'exemple suivant illustre cette question :
La fonction g() peut être appelée et de la fonction main(), et de la fonction f(). Ainsi, c'est le contexte de l'exécution du programme qui décidera, à tout moment, à quel endroit revenir lorsque la fonction g() terminera de s'exécuter.
Ceci n'est pas un problème simple. Il ne serait pas raisonnable pour le solutionner de copier intégralement le code de l'appelé à l'endroit de chaque appel[4]. Il nous faut donc un autre mécanisme pour nous en sortir. Il faut :
Le contexte d'appel d'une fonction comprend plusieurs éléments importants. Nommons en deux, simplement pour démontrer notre point :
Quelles étaient les valeurs des différents registres avant que la fonction soit appelée?
C'est une question pertinente : si la fonction est appelée à l'intérieur d'une boucle for, par exemple, la valeur du registre servant de compteur doit être protégée de façon à ce que la boucle se poursuive normalement, et ce peu importe le traitement fait dans la fonction appelée.
Concrètement : une fonction utilisant le registre ECX appelle une fonction bingo(). La fonction bingo() ne peut pas savoir que la fonction appelante utilisait le registre ECX, mais elle sait qu'elle peut avoir besoin de ce registre pour son propre fonctionnement.
Il faut donc qu'une copie du registre ECX soit faite avant l'appel, et que l'appelant puisse récupérer cette copie et la remettre dans ECX après l'appel pour que son propre fonctionnement demeure valide.
La question se pose sous une lueur plus générale... À quelle instruction en était-on dans l'exécution de la fonction appelante au moment de l'appel? (exprimé autrement : quelle était alors la valeur du registre IP?)
Nous l'avons mentionné précédemment : si la valeur de IP n'est pas prise en note au moment de l'appel, l'endroit précis de l'appel sera perdu, et le retour au point de l'appel deviendra impossible.
Nous avons donc besoin, pour être en mesure d'appeler un sous-programme, de conserver au préalable le contexte de l'appelant. La question naturelle qui se pose est donc : où conserver ces valeurs au moment de l'appel de telle façon qu'on puisse les récupérer lors de la conclusion de la fonction appelée?
Il existe une structure de données dont l'emploi est naturel pour résoudre ce genre de problème : la pile. Le pseudo-code d'appel de fonction suivant l'illustre :
// code avant l'appel
Sauvegarder le contexte
Sauvegarder le lieu de l'appel
Début la fonction
// code de la fonction
Fin de la fonction
Récupérer le lieu de l'appel
Récupérer le contexte
// code après l'appel
Si nous avions une pile entre les mains, nous aurions naturellement pu écrire (en pseudo assembleur – sauvegarder ou récupérer le contexte prend plusieurs instructions!) :
; code avant l'appel
PUSH (contexte) ; mettre le contexte sur la pile
«PUSH (IP)» ; mettre l'adresse de retour sur la pile
CALL (fonction) ; IP ← adresse de «fonction»
; code de la fonction
RET ; IP ← valeur sur la pile (adresse de retour)
POP (contexte) ; reprendre le contexte sur la pile
; code après l'appel
Ce pseudo-code-assembleur est incomplet (on y fait notamment abstraction des paramètres de la fonction appelée), mais montre que la mécanique d'appel de fonctions se prête tout naturellement à l'utilisation d'une pile. Et nous avons effectivement un tel outil : la pile d'exécution.
Écrit tel quel, ce code ne fonctionne pas, car PUSH IP n'a pas de sens – cette instruction étant une instruction assembleur, elle incrémenterait IP, brisant la logique du code. En réalité, c'est l'instruction CALL qui conserve la valeur de IP, et c'est l'instruction RET qui la récupère.
La pile d'exécution d'un processus est un espace en mémoire alloué à ce processus, et dont le contenu fluctue à chaque changement de contexte. Elle sert particulièrement à gérer les appels de sous-programmes, le passage des paramètres et les variables dites « automatiques[5] » en langage C ou C++.
La pile d'exécution est d'abord, comme son nom l'indique, une pile, c'est-à-dire une structure de données dotée de certaines particularités très utiles.
Pour comprendre le cas particulier de la pile d'exécution, il faut s'assurer de comprendre au moins de façon intuitive le cas général d'une pile. La page suivante offre un court schéma de révision du concept.
L'environnement intégré Visual Studio offre entre autres choses un outil appelé la pile d'appel (Call Stack). Celle-ci ne présente pas tous les détails propres à la pile d'exécution, mais permet de suivre (sous la forme d'une pile) la séquence d'appels de fonctions avec le dévermineur.
Dans l'image ci-dessus, on voit par exemple que la fonction main() a appelé à sa sixième ligne de code la fonction somme(), qui elle en est à la première ligne de son propre code. Lorsque bien utilisé, la pile d'appels permet de retracer la séquence d'appels qui mène à une situation donnée, et peut s'avérer d'une grande utilité lorsqu'on cherche à comprendre « ce qui s'est passé pour en arriver à cette situation de c** »...
Une pile c'est un lieu où on peut entreposer et d'où on peut accéder à des objets. On peut se demander si elle est vide, si elle est pleine[6], et on peut y ajouter (push) ou en enlever (pop) un objet à la fois.
Ces propriétés relativement symétriques d'ajout et de retrait sont fondamentales à la pile, et en font la structure essentielle qu'elle est en informatique. En effet, pensons un instant à tout ce qui est ainsi symétrique dans le Monde Merveilleux de l'InformatiqueMD :
|
![]() |
![]() |
Chaque appel de sous-programme implique au moins deux acteurs : l'appelant et l'appelé.
Pour ce qui est du code généré, le travail à faire correspond donc en gros à ce qui suit :
Pas une mince tâche, n'est-ce pas? Mais la pile d'exécution est l'outil tout désigné pour permettre l'implantation élégante de cette mécanique.
Voyons un peu ce qui se passe : nous avons ici un programme désassemblé par Visual Studio (version 6) qui présente le code suivant :
Voici une vue d'ensemble de notre programme :
1: int somme(int a, int b);
2:
3: int main()
4: {
5: int c= somme(5, 3);
6: }
7:
8: int somme(int a, int b)
9: {
10: return (a + b);
11: } // somme
Nous ferons la lecture de ce code selon l'ordre dans lequel il sera parcouru, pour mieux en comprendre les diverses subtilités.
Les numéros de ligne du code C++ ont été laissés dans le code présenté pour vous aider à mieux vous orienter.
Certaines instructions sont moins pertinentes à notre étude. C'est pourquoi nous ne couvrirons pas la totalité du code assembleur présenté, nous restreignant aux éléments étudiés.
1: int somme(int a, int b);
2:
3: int main()
4: {
00401020 push ebp
00401021 mov ebp,esp
00401023 sub esp,44h
00401026 push ebx
00401027 push esi
00401028 push edi
00401029 lea edi,[ebp-44h]
0040102C mov ecx,11h
00401031 mov eax,0CCCCCCCCh
00401036 rep stos dword ptr [edi]
5: int c= somme(5, 3);
00401038 push 3
0040103A push 5
0040103C call @ILT+0(somme) (00401005)
00401041 add esp,8
00401044 mov dword ptr [ebp-4],eax
6: }
00401047 pop edi
00401048 pop esi
00401049 pop ebx
0040104A add esp,44h
0040104D cmp ebp,esp
0040104F call __chkesp (004010a0)
00401054 mov esp,ebp
00401056 pop ebp
00401057 ret
Nous avons jusqu'ici escamoté les premières lignes de la fonction main() , dans le but avoué de nous simplifier l'existence. Toutefois, bien que cette fonction soit le point d'entrée de l'exécutable résultant de l'édition des liens dans un projet, il est facile d'oublier qu'elle demeure un sous-programme et, au même titre que les autres sous-programme, son appel est soumis à une certaine mécanique.
La ligne 3 du programme en exemple est :
3: int main()
4: {
00401020 push ebp
00401021 mov ebp,esp
00401023 sub esp,44h
00401026 push ebx
00401027 push esi
00401028 push edi
00401029 lea edi,[ebp-44h]
0040102C mov ecx,11h
00401031 mov eax,0CCCCCCCCh
00401036 rep stos dword ptr [edi]
Lorsque le point d'entrée du main() est rencontré, on voit trois lignes de code assembleur bien spéciales apparaître, dont deux sont vraiment essentielles à un appel de fonction correct :
Chaque sous-programme utilise sa propre base (sa propre valeur de EBP), qui diffère de celle des autres sous-programmes; il faut donc absolument conserver sur la pile une copie de la base de calcul de la fonction appelante avant de la modifier pour qu'elle devienne celle de la fonction appelée.
C'est à partir de l'adresse indiquée par EBP que le sous-programme en cours d'exécution retrouvera ses variables locales. Avoir une valeur inexacte dans ce registre, ne serait-ce que d'un seul octet, ferait en sorte que le programme tout entier devienne soudainement incorrect.
La ligne 6 nous offre la situation inverse : le sous-programme main() se termine et doit rétablir l'état de la pile de telle façon que la fonction appelante – quelle qu'elle soit – puisse retomber correctement sur ses pattes.
Les instructions nécessaires, en symétrie avec celles à l'étape 1 plus haut, sont donc :
6: }
00401047 pop edi
00401048 pop esi
00401049 pop ebx
0040104A add esp,44h
0040104D cmp ebp,esp
0040104F call __chkesp (004010a0)
00401054 mov esp,ebp
00401056 pop ebp
00401057 ret
On voit donc que :
Effectuer ces deux opérations fait en sorte de remettre en place les valeurs du EBP et de ESS telles qu'elles étaient avant le début du main(), pour que le sous-programme l'ayant appelé retrouve son contexte.
Nous reviendrons sur le rôle de l'instruction ret sous peu, alors que nous examinerons l'instruction call.
La ligne 5 nous permet de voir explicitement la mécanique d'un appel de fonction, dans ce cas la fonction somme().
Nous trouvons donc ici :
5: int c= somme(5, 3);
00401038 push 3
0040103A push 5
0040103C call @ILT+0(somme) (00401005)
00401041 add esp,8
00401044 mov dword ptr [ebp-4],eax
Les éléments à noter sont :
Examinons finalement ce qui se passe dans le cadre de la fonction appelée :
8: int somme(int a, int b)
9: {
00401070 push ebp
00401071 mov ebp,esp
00401073 sub esp,40h
00401076 push ebx
00401077 push esi
00401078 push edi
00401079 lea edi,[ebp-40h]
0040107C mov ecx,10h
00401081 mov eax,0CCCCCCCCh
00401086 rep stos dword ptr [edi]
10: return (a + b);
00401088 mov eax,dword ptr [ebp+8]
0040108B add eax,dword ptr [ebp+0Ch]
11: } // somme
0040108E pop edi
0040108F pop esi
00401090 pop ebx
00401091 mov esp,ebp
00401093 pop ebp
00401094 ret
Les lignes 9: et 11: suivent la même logique que les lignes 4: et 6: couvertes précédemment dans le cadre du sous-programme main().
La ligne 10:, toutefois, nous montre (de manière obscure, ce qui est triste) comment sont gérés les paramètres dans le cadre de leur utilisation dans la fonction appelée :
Le schéma suivant montre l'état de la pile d'exécution au moment où la fonction appelée doit accéder aux paramètres a et b :
Où sont les paramètres a et b? Pour comprendre, voici l'équation pour trouver l'adresse d'un paramètre : valeur de ESS-EBP (rappel : EBP=ESP après les deux premières instructions d'une fonction) à laquelle on enlève la taille de deux mots mémoire (car au-dessus de la pile se trouvent alors l'ancienne valeur d'EBP, de même que l'adresse de retour empilée par « CALL et destinée à être dépilée par RET), puis à laquelle on enlève encore la taille des paramètres précédents (s'il y a lieu).
Dans notre cas :
Le sous-programme ne dépile pas ses paramètres : il triche sur la structure de la pile en allant voir directement à une adresse où elle sait pouvoir trouver les paramètres qui lui sont confiés. Il sait que l'adresse de retour se trouve sur le dessus de la pile (juste au dessus des paramètres) au moment où il débute son exécution, et il sait avoir empilé la valeur de EBP pour l'appelant tout juste au-dessus.
Offrons-nous donc une vue d'ensemble des manipulations requises pour générer le code assembleur représentant un appel de fonction C++ correct :
Étudions maintenant, de manière sommaire, les conventions de passage de paramètres et de valeurs de retour par adresse (pointeur), par référence et par valeur, mais pour un type de données autre qu'un type primitif.
Soit la déclaration de type structuré suivante :
1: struct sl { long l1, l2; };
... les prototypes suivants :
3: sl f(sl& s); // passage de paramètre par référence
4: sl g(sl* s); // passage de paramètre par adresse
5: sl h(sl s); // passage de paramètre par valeur
qui déclarent chacun une fonction qui retourne une copie d'un sl, et finalement la fonction main() suivante :
7: int main()
8: {
9: sl l= { 0, 0 },
10: r; // s est initialisé, r ne l'est pas
11: r= f(l); // passage par référence d'un sl
12: r= g(&l); // passage par adresse d'un sl
13: r= h(l); // passage par valeur d'un sl
14: }
Regardons ensembles les différences entre chacune. Avant tout, remarquons que les prototypes sont associés par le compilateur à un endroit en mémoire se trouvant avant le début du programme principal (pouvez-vous imaginer pourquoi?) :
@ILT+0(?somme@@YAHHH@Z):
00401005 jmp somme (00401070)
@ILT+5(_main):
0040100A jmp main (0040b4f0)
0040100F jmp h (0040b610)
00401014 jmp g (0040b5d0)
00401019 jmp f (0040b590)
Le passage d'un objet par référence (en exemple ici : la fonction f()) implique des manipulations un peu particulières.
À l'appel :
11: r= f (l); // passage par référence d'un sl
0040B516 lea eax,[ebp-8]
0040B519 push eax
0040B51A call @ILT+20(f) (00401019)
0040B51F add esp,4
0040B522 mov dword ptr [ebp-18h],eax
0040B525 mov dword ptr [ebp-14h],edx
0040B528 mov ecx,dword ptr [ebp-18h]
0040B52B mov dword ptr [ebp-10h],ecx
0040B52E mov edx,dword ptr [ebp-14h]
0040B531 mov dword ptr [ebp-0Ch],edx
Quelques explications :
En effet, un passage de paramètre par référence est, de manière déguisée, un passage de paramètre par adresse. On déposera en fait sur la pile l'adresse de l'objet auquel une référence est passée, et le compilateur fera les démarches nécessaires pour dissimuler cet état de fait au programmeur C++.
C'est parce que le sous-programme appelé connaît l'adresse de l'objet passé en paramètre à l'appel qu'il peut modifier sa valeur.
L'instruction call appelle la fonction, de la même manière que pour les fonctions déjà couvertes dans ce document. Remarquez que l'escamotage des paramètres sur la pile (le add esp,4 suivant l'instruction call) ne saute que 4 bytes... cela parce qu'à l'appel, on n'a empilé qu'une simple adresse;.
Le passage de paramètres par référence tend donc à être très efficace en terme de performance (surtout lorsque comparé à un passage de paramètre par valeur sur des objets complexes). C'est moins lourd de passer en paramètre l'adresse d'un struct ou d'un objet arbitrairement complexe – de ne passer que 4 bytes sur une machine 32 bits – que de passer une copie complète de cet objet.
Les instructions mov dword ptr [ebp-18h],eax et mov dword ptr [ebp-14h],edx demandent un peu d'explications :
Dans le cas simple d'une fonction qui retourne un int, la valeur de retour sera souvent dans eax.
Dans la fonction appelée, on trouve :
16: sl f (sl& s)
17: {
0040B590 push ebp
0040B591 mov ebp,esp
0040B593 sub esp,40h
0040B596 push ebx
0040B597 push esi
0040B598 push edi
0040B599 lea edi,[ebp-40h]
0040B59C mov ecx,10h
0040B5A1 mov eax,0CCCCCCCCh
0040B5A6 rep stos dword ptr [edi]
18: s.l1= 3;
0040B5A8 mov eax,dword ptr [ebp+8]
0040B5AB mov dword ptr [eax],3
19: s.l2= 4;
0040B5B1 mov ecx,dword ptr [ebp+8]
0040B5B4 mov dword ptr [ecx+4],4
20: return s;
0040B5BB mov edx,dword ptr [ebp+8]
0040B5BE mov eax,dword ptr [edx]
0040B5C0 mov edx,dword ptr [edx+4]
21: }
0040B5C3 pop edi
0040B5C4 pop esi
0040B5C5 pop ebx
0040B5C6 mov esp,ebp
0040B5C8 pop ebp
0040B5C9 ret
Remarquez ce qui suit :
La valeur de retour est produite selon une mécanique plutôt sympathique :
C'est important de faire cette distinction parce que même si le paramètre «s» est une référence à l'objet original, la valeur de retour de la fonction est une copie de ce paramètre (et donc un objet distinct).
Le passage d'un objet par adresse (en exemple ici : la fonction g()) se génère à peu près comme suit.
À l'appel :
12: r= g (&l); // passage par adresse d'un sl
0040B534 lea eax,[ebp-8]
0040B537 push eax
0040B538 call @ILT+15(g) (00401014)
0040B53D add esp,4
0040B540 mov dword ptr [ebp-20h],eax
0040B543 mov dword ptr [ebp-1Ch],edx
0040B546 mov ecx,dword ptr [ebp-20h]
0040B549 mov dword ptr [ebp-10h],ecx
0040B54C mov edx,dword ptr [ebp-1Ch]
0040B54F mov dword ptr [ebp-0Ch],edx
La seule remarque pertinente vraiment à faire ici est que, mis à part certaines distinctions numériques dues à l'emplacement légèrement différent en mémoire de certaines instructions, un appel de fonction avec paramètre passé par adresse est virtuellement identique, au niveau du code machine, à un appel de fonction avec paramètre passé par référence. C'est fort raisonnable quand on réalise que dans un passage de paramètres par référence, c'est en fait l'adresse du paramètre qui, en cachette, est passée au sous-programme.
Les fonctions f() et g() retournant toutes deux des copies de sl, leurs mécaniques respectives de génération/ récupération des valeurs de retour sont aussi pratiquement identiques.
Dans la fonction appelée, on trouve :
23: sl g (sl* s)
24: {
0040B5D0 push ebp
0040B5D1 mov ebp,esp
0040B5D3 sub esp,40h
0040B5D6 push ebx
0040B5D7 push esi
0040B5D8 push edi
0040B5D9 lea edi,[ebp-40h]
0040B5DC mov ecx,10h
0040B5E1 mov eax,0CCCCCCCCh
0040B5E6 rep stos dword ptr [edi]
25: s-> l1= 3;
0040B5E8 mov eax,dword ptr [ebp+8]
0040B5EB mov dword ptr [eax],3
26: s-> l2= 4;
0040B5F1 mov ecx,dword ptr [ebp+8]
0040B5F4 mov dword ptr [ecx+4],4
27: return *s;
0040B5FB mov edx,dword ptr [ebp+8]
0040B5FE mov eax,dword ptr [edx]
0040B600 mov edx,dword ptr [edx+4]
28: }
0040B603 pop edi
0040B604 pop esi
0040B605 pop ebx
0040B606 mov esp,ebp
0040B608 pop ebp
0040B609 ret
À nouveau, remarquez que le code machine généré pour deux fonctions, une manipulant une référence à un sl et l'autre manipulant un pointeur de sl, si elles accomplissent la même fonction, est essentiellement identique.
Les références sont un outil qui permet de profiter d'une partie de la puissance des adresses et des pointeurs en C++ mais sans devoir courir les risques inhérents à la manipulation de pointeurs. Ce sont là deux outils de programmation extrêmement puissants.
Le passage d'un objet par valeur (en exemple ici : la fonction h()) se génère à peu près comme suit.
À l'appel :
13: r= h (l); // passage par valeur d'un sl
0040B552 mov eax,dword ptr [ebp-4]
0040B555 push eax
0040B556 mov ecx,dword ptr [ebp-8]
0040B559 push ecx
0040B55A call @ILT+10(h) (0040100f)
0040B55F add esp,8
0040B562 mov dword ptr [ebp-28h],eax
0040B565 mov dword ptr [ebp-24h],edx
0040B568 mov edx,dword ptr [ebp-28h]
0040B56B mov dword ptr [ebp-10h],edx
0040B56E mov eax,dword ptr [ebp-24h]
0040B571 mov dword ptr [ebp-0Ch],eax
Encore une fois, le mécanisme de récupération des valeurs produites par la fonction est virtuellement identique à celui présenté pour les fonctions f() et g(), ce qui est absolument normal (le contraire serait pour le moins suspect).
Notez par contre que le paramètre passé par valeur étant un struct composé de deux long, il faut déposer (ici par deux push) sur la pile huit bytes, soit la description complète de la variable l au moment de l'appel (l.l1 et l.l2).
Dans la fonction appelée, on trouve :
30: sl h (sl s)
31: {
0040B610 push ebp
0040B611 mov ebp,esp
0040B613 sub esp,40h
0040B616 push ebx
0040B617 push esi
0040B618 push edi
0040B619 lea edi,[ebp-40h]
0040B61C mov ecx,10h
0040B621 mov eax,0CCCCCCCCh
0040B626 rep stos dword ptr [edi]
32: s.l1= 3;
0040B628 mov dword ptr [ebp+8],3
33: s.l2= 4;
0040B62F mov dword ptr [ebp+0Ch],4
34: return s;
0040B636 mov eax,dword ptr [ebp+8]
0040B639 mov edx,dword ptr [ebp+0Ch]
35: }
0040B63C pop edi
0040B63D pop esi
0040B63E pop ebx
0040B63F mov esp,ebp
0040B641 pop ebp
0040B642 ret
Le code de la fonction elle-même, qui manipule une copie (se trouvant décrite sur la pile) du paramètre formel, est relativement simple : pas besoin de charger une adresse dans un registre et de faire des manipulations comme celles nécessitées pour les paramètres par référence ou par adresse.
On retrouve donc, pour un paramètre passé par valeur, une version similaire mais simplifiée des accès aux membres de la structure manipulée, celle-ci n'étant qu'une copie de l'original se trouvant sur la pile.
Un dernier détail digne de mention : l'attribution de l'espace pour les variables automatiques, ou locales à une fonction.
Comme dans le cas de la méthodologie de gestion des paramètres effectifs d'une sous-programme, le code assembleur présenté par le débogueur de visualk Studio ne nous est pas d'un grand secours pour ce qui est de présenter comment l'attribution de l'espace propre aux variables locales est géré au niveau du code machine.
Toutefois, il peut à l'occasion devenir important de comprendre ce mécanisme (tout(e) informaticien(ne) rencontre un problème relié à un débordement de pile à un moment donné ou l'autre dans sa carrière).
Prenons par exemple le programme C++ suivant :
1: int somme(int);
2: int main()
3: {
4: int x= 3;
5: x= somme(x);
6: }
La fonction somme() possède une variable locale b pour laquelle on doit réserver un espace de la taille d'un int. Où donc réservera-t-on cet espace si on veut que ce programme fonctionne, dans le cas général?[10]
Vous vous en doutez sûrement : cet espace est attribué sur la pile d'exécution. C'est en fait le seul endroit répondant aux besoins de flexibilité propres à la mécanique d'attribution d'espace pour les variables automatiques.
Les paramètres effectifs (ceux vus et manipulés par la fonction appelée) et les variables automatiques de la fonction appelée sont tous deux gérés de la même manière.
La plus grosse différence entre un paramètre effectif et une variable automatique, sur le plan de la mécanique, est que :
Ainsi, la fonction somme() suivante :
7: int somme(int a)
8: {
9: int b = 5;
10: return b + a;
11: }
...amènera le code suivant :
7: int somme(int a)
8: {
0040B590 push ebp
0040B591 mov ebp,esp
...et, en escamotant des instructions moins pertinentes à notre propos...
9: int b = 5;
0040B5A8 mov dword ptr [ebp-4],5
10: return b + a;
0040B5AF mov eax,dword ptr [ebp-4]
0040B5B2 add eax,dword ptr [ebp+8]
11: }
0040B5B5 pop edi
0040B5B6 pop esi
0040B5B7 pop ebx
0040B5B8 mov esp,ebp
0040B5BA pop ebp
0040B5BB ret
On note donc que, puisque ebp contient la valeur de esp telle qu'elle était au début du sous-programme, et que la variable automatique b se trouve au-dessus de la pile (à [ebp-4], en se souvenant que la pile va en descendant, donc que les valeurs inférieures à ebp sont considérées sur la pile, pas en dessous).
Un processus est un programme en cours d'exécution, muni de ses données.
Tout processus possède une pile d'exécution. Les opérations en assembleur permettant d'y empiler et d'en dépiler des données sont respectivement push et pop.
La pile d'exécution d'un processus rend possible :
La valeur de retour d'une fonction ne se trouve toutefois pas offerte à la fonction appelante via la pile. En général, on passera ici par des registres.
Les calculs d'adressage de variables à l'intérieur d'une fonction sont faits relativement au registre de base, nommé ebp en assembleur x86. La valeur du registre de base doit être conservée, lors d'un appel de sous-programme, par le sous-programme appelée. Celui-ci en remplacera ensuite le contenu par la valeur du registre de pile, esp, ceci parce que c'est du dessus de la pile qu'est calculé l'emplacement des différentes variables automatiques d'un sous-programme. Chaque sous-programme voit esp et ebp différemment; c'est ce qui permet toute la dynamique d'exécution du programme. |
|
Tout sous-programme doit restaurer la valeur du registre de base une fois son exécution terminée, pour que le sous-programme appelant poursuive ses opérations normales. |
|
Les références sont en fait des adresses déguisées.
L'instruction assembleur call empile l'adresse à laquelle revenir une fois terminée l'exécution de la fonction appelée. L'instruction ret récupère cette adresse et y saute, assurant la poursuite normale des opérations du processus.
Quelques liens pour enrichir le propos.
[1] Instruction débutant par la lettre J, pour Jump, mais autre que JMP, qui est un saut inconditionnel.
[2] Rappel : un processus est un programme en cours d'exécution, muni de ses données.
[3] Nous utiliserons ici « fonction » au sens large de sous-programme, dans la tradition du langage C.
[4] En fait, ce serait impossible, pour différentes raisons bien concrètes.
[5] Les variables automatiques sont les variables locales aux fonctions que vous utilisez maintenant de manière relativement naturelle.
[6] Une pile sera pleine si on ne peut plus y empiler d'objets, faute d'espace. La manière de déterminer ceci varie selon l'implantation de pile choisie.
[7] puisque les langages C et C++ permettent de déclarer des fonctions variadiques, donc ayant un nombre variable de paramètres, il faut absolument que le premier paramètre passé soit celui se trouvant le plus près du dessus de la pile une fois l'appel effectué. En effet, dans le cas contraire, il serait impossible de savoir si le paramètre « du dessus » est vraiment le 1er, le 3e, le 12e ou le 60e paramètre du sous-programme.
[8] pas besoin de faire de pop si on se fout des valeurs en question; l'appelant n'a qu'à sauter par dessus l'espace occupé par les paramètres.
[9] Il y a deux écoles de pensée : ceux qui disent que l'appelant devrait empiler les registres dont il se sert pour éviter qu'ils soient bousillés par l'appelé, parce que c'est l'appelant qui sait ce dont il se sert; et l'autre qui dit que l'appelé devrait empiler les registres dont il se servira parce que c'est l'appelé qui sait ce qu'il utilisera. Les deux approches se défendent.
[10] ... donc dans le cas où la fonction peut être appelé de plusieurs endroits, et où il devient impossible de prédire avec exactitude quand et comment elle sera effectivement appelée.