Contexte
Les processeurs ont une structure interne basée sur un nombre de registres limité et un accès à la mémoire. Le rôle du compilateur est de transformer le code C et/ou C++ en langage machine. Nos codes sont structurés avec des fonctions et des classes. Nous allons nous concentrer sur la façon dont le compilateur transforme un appel de fonction en langage machine et quelles sont les conséquences pour le programme.
Cela s’appelle une convention d’appel qui peut varier selon le compilateur et parfois l’architecture cible (OS et/ou processeur). La convention d’appel va influer sur les points suivants :
- L’ordre d’empilage des arguments (à partir du premier ou à partir du dernier)
- Passage ou non des paramètres par des registres du processeur
- Appel de la fonction avec une adresse absolue ou relative par rapport à la position actuelle du « instruction pointer ».
- Comment sont désempilés les arguments ? (dans la fonction appelée ou par l’appelant)
- La méthode de retour du résultat de la fonction
Le but de cet article est de vous aider à comprendre ce que fait le compilateur et éventuellement comment rendre plus performant votre code.
La convention par défaut du C
La convention définit par le langage C consiste à empiler les arguments de droite à gauche et seront dépilés par l’appelant. Le résultat est retourné via un registre du processeur (sur Intel : AL, AX, EAX ou RAX).
Nous allons vérifier cela en utilisant un petit exemple :
#include <stdio.h> int test_cdecl(int x, const char *text) { const char *ptr = text; while (0 != *ptr) { ptr++; } return x + int( ptr - text ); } int main( int argc, char* argv[] ) { // cdecl int x = test_cdecl( 0, "toto" ); printf( "test_cdecl( 0, \"toto\" ) = %d\n", x ); return 0; }
Nous avons deux fonctions main et test_cdecl. Le code généré par le compilateur (en debug pour garder le lien C++ vers langage machine) est le suivant :
int test_cdecl(int x, const char *text) { 009E1720 push ebp 009E1721 mov ebp,esp 009E1723 sub esp,0CCh 009E1729 push ebx 009E172A push esi 009E172B push edi 009E172C lea edi,[ebp-0CCh] 009E1732 mov ecx,33h 009E1737 mov eax,0CCCCCCCCh 009E173C rep stos dword ptr es:[edi] const char *ptr = text; 009E173E mov eax,dword ptr [text] 009E1741 mov dword ptr [ptr],eax while (0 != *ptr) 009E1744 mov eax,dword ptr [ptr] 009E1747 movsx ecx,byte ptr [eax] 009E174A test ecx,ecx 009E174C je test_cdecl+39h (09E1759h) { ptr++; 009E174E mov eax,dword ptr [ptr] 009E1751 add eax,1 009E1754 mov dword ptr [ptr],eax } 009E1757 jmp test_cdecl+24h (09E1744h) return x + int( ptr - text ); 009E1759 mov eax,dword ptr [ptr] 009E175C sub eax,dword ptr [text] 009E175F add eax,dword ptr [x] } 009E1762 pop edi 009E1763 pop esi 009E1764 pop ebx } 009E1765 mov esp,ebp 009E1767 pop ebp 009E1768 ret int main( int argc, char* argv[] ) { 009E1840 push ebp 009E1841 mov ebp,esp 009E1843 sub esp,0CCh 009E1849 push ebx 009E184A push esi 009E184B push edi 009E184C lea edi,[ebp-0CCh] 009E1852 mov ecx,33h 009E1857 mov eax,0CCCCCCCCh 009E185C rep stos dword ptr es:[edi] // cdecl int x = test_cdecl( 0, "toto" ); 009E185E push offset string "toto" (09E7B30h) 009E1863 push 0 009E1865 call test_cdecl (09E1154h) 009E186A add esp,8 009E186D mov dword ptr [x],eax printf( "test_cdecl( 0, \"toto\" ) = %d\n", x ); 009E1870 mov eax,dword ptr [x] 009E1873 push eax 009E1874 push offset string "test_cdecl( 0, "toto" ) = %d\n" (09E7B38h) 009E1879 call _printf (09E1339h) 009E187E add esp,8 return 0; 009E1881 xor eax,eax } 009E1883 pop edi 009E1884 pop esi 009E1885 pop ebx 009E1886 add esp,0CCh 009E188C cmp ebp,esp 009E188E call __RTC_CheckEsp (09E1122h) 009E1893 mov esp,ebp 009E1895 pop ebp 009E1896 ret
Concentrons-nous sur l’appel à test_cdecl :
009E185E push offset string "toto" (09E7B30h) 009E1863 push 0 009E1865 call test_cdecl (09E1154h) 009E186A add esp,8
En bleu, « push » est l’instruction processeur pour empiler les arguments : on empile bien de droite à gauche les arguments… Puis on appelle la routine ‘test_cdecl’ via l’instruction « call » (en vert). Pour dépiler les arguments, on change la valeur du registre de pile (en rouge).
La convention standard
Cette convention est la convention utilisée par défaut dans le langage Pascal et celle de toutes les API du Win32 SDK. Elle a souvent été utilisée pour faire communiquer écrits dans des langages différents. Dans cette convention les arguments sont empilés de droite à gauche. La fonction appelée dépile elle-même ses arguments.
Que ce soit sous gcc ou Visual C++, il faut employer le mot-clé __sdtcall entre le type de retour et le nom de la fonction.
Nous allons vérifier cela en utilisant le même petit exemple que pour cdecl mais en y changeant la convention:
int __stdcall test_stdcall(int x, const char *text) { const char *ptr = text; while (0 != *ptr) { ptr++; } return x + int(ptr - text); } int main( int argc, char* argv[] ) { // stdcall int x = test_stdcall(0, "toto"); printf("test_stdcall( 0, \"toto\" ) = %d\n", x); return 0; }
Le code généré par le compilateur (en debug) est le suivant :
int __stdcall test_stdcall(int x, const char *text) { 001A1770 push ebp 001A1771 mov ebp,esp 001A1773 sub esp,0CCh 001A1779 push ebx 001A177A push esi 001A177B push edi 001A177C lea edi,[ebp-0CCh] 001A1782 mov ecx,33h 001A1787 mov eax,0CCCCCCCCh 001A178C rep stos dword ptr es:[edi] const char *ptr = text; 001A178E mov eax,dword ptr [text] 001A1791 mov dword ptr [ptr],eax while (0 != *ptr) 001A1794 mov eax,dword ptr [ptr] 001A1797 movsx ecx,byte ptr [eax] 001A179A test ecx,ecx 001A179C je test_stdcall+39h (01A17A9h) { ptr++; 001A179E mov eax,dword ptr [ptr] 001A17A1 add eax,1 001A17A4 mov dword ptr [ptr],eax } 001A17A7 jmp test_stdcall+24h (01A1794h) return x + int(ptr - text); 001A17A9 mov eax,dword ptr [ptr] 001A17AC sub eax,dword ptr [text] 001A17AF add eax,dword ptr [x] } 001A17B2 pop edi 001A17B3 pop esi 001A17B4 pop ebx 001A17B5 mov esp,ebp 001A17B7 pop ebp 001A17B8 ret 8 int main( int argc, char* argv[] ) { 001A18B0 push ebp 001A18B1 mov ebp,esp 001A18B3 sub esp,0CCh 001A18B9 push ebx 001A18BA push esi 001A18BB push edi 001A18BC lea edi,[ebp-0CCh] 001A18C2 mov ecx,33h 001A18C7 mov eax,0CCCCCCCCh 001A18CC rep stos dword ptr es:[edi] // stdcall int x = test_stdcall(0, "toto"); 001A18CE push offset string "toto" (01A7B30h) 001A18D3 push 0 001A18D5 call test_stdcall (01A137Ah) 001A18DA mov dword ptr [x],eax printf("test_stdcall( 0, \"toto\" ) = %d\n", x); 001A18DD mov eax,dword ptr [x] 001A18E0 push eax 001A18E1 push offset string "test_stdcall( 0, "toto" ) = %d\n" (01A7B38h) 001A18E6 call _printf (01A1339h) 001A18EB add esp,8 return 0; 001A18EE xor eax,eax } 001A18F0 pop edi 001A18F1 pop esi 001A18F2 pop ebx 001A18F3 add esp,0CCh 001A18F9 cmp ebp,esp 001A18FB call __RTC_CheckEsp (01A1122h) 001A1900 mov esp,ebp 001A1902 pop ebp 001A1903 ret
Il n’y a que peu de différences entre l’appel cdecl ou stdcall en terme code généré. Seul l’endroit où le pointeur de pile est mis à jour.
Le cas des fonctions ‘inline’
Les fonctions ‘inline’ sont une variante typée des macros #define… Elles ont d’abord été normées dans le C++ avant d’être ajoutée à la norme du C (C90) bien que supportée par certains compilateurs avant la norme.
Lorsque le compilateur rencontre une fonction inline, elle stocke cette fonction dans une table style clé/valeur avec pour clé le prototype de la fonction (sa signature) et comme valeur le code à substituer. Cette table a une taille limitée (et la taille dépend du compilateur utilisé) : il m’est déjà arrivé d’avoir des alertes de compilation sous un vieux Visual Studio m’indiquant qu’une fonction ne pouvait pas être « inlinée » faute d’espace… Lorsque le compilateur rencontre un appel à une fonction présente dans la table des fonctions inline, il va remplacer l’appel par le code de la fonction : on économisera le passage des arguments par la pile d’appel et le compilateur pourra appliquer plus de règles d’optimisations sur le code obtenu. Cette forme d’optimisation n’est disponible qu’en compilation release..
Voici comment on déclare une fonction inline :
inline int test_inline(int x, const char *text) { const char *ptr = text; while (0 != *ptr) { ptr++; } return x + int(ptr - text); }
La convention rapide ou fastcall
Cette convention d’appel a été mise en place sous Windows pour les processeurs x86 et elle est supportée par le compilateur de Visual C++ et gcc. Le principe de la convention d’appel « __fastcall » est que les arguments des fonctions doivent être passés dans les registres, lorsque cela est possible. Cette technique permet d’éviter d’empiler les 2 premiers arguments et donc cela permet souvent des gains de performances pour les fonctions ayant un ou deux arguments. Les deux premiers DWORD ou arguments plus petits qui figurent dans la liste d’arguments de gauche à droite sont transmis dans les registres ; tous les autres arguments sont transmis sur la pile de droite à gauche. La fonction appelée enlève les arguments de la pile. Le mot clé « __fastcall » est accepté et ignoré par les compilateurs qui ciblent ARM et x64 architectures ; sur un processeur x64, par convention, les quatre premiers arguments sont passés dans les registres quand cela est possible, et les arguments supplémentaires sont passés sur la pile.
Voici comment on déclare la fonction :
int __fastcall test_fastcall(int x, int y) { return x + y; }
Voici le code généré par le compilateur :
int __fastcall test_fastcall(int x, int y) { 007317A0 push ebp 007317A1 mov ebp,esp 007317A3 sub esp,0D8h 007317A9 push ebx 007317AA push esi 007317AB push edi 007317AC push ecx 007317AD lea edi,[ebp-0D8h] 007317B3 mov ecx,36h 007317B8 mov eax,0CCCCCCCCh 007317BD rep stos dword ptr es:[edi] 007317BF pop ecx 007317C0 mov dword ptr [y],edx 007317C3 mov dword ptr [x],ecx return x + y; 007317C6 mov eax,dword ptr [x] 007317C9 add eax,dword ptr [y] } 007317CC pop edi 007317CD pop esi 007317CE pop ebx 007317CF mov esp,ebp 007317D1 pop ebp 007317D2 ret int main( int argc, char* argv[] ) { 00731900 push ebp 00731901 mov ebp,esp 00731903 sub esp,0CCh 00731909 push ebx 0073190A push esi 0073190B push edi 0073190C lea edi,[ebp-0CCh] 00731912 mov ecx,33h 00731917 mov eax,0CCCCCCCCh 0073191C rep stos dword ptr es:[edi] // fastcall int x = test_fastcall(5, 3); 0073191E mov edx,3 00731923 mov ecx,5 00731928 call test_fastcall (073113Bh) 0073192D mov dword ptr [x],eax printf("test_fastcall( 5, 3 ) = %d\n", x); 00731930 mov eax,dword ptr [x] 00731933 push eax 00731934 push offset string "test_fastcall( 5, 3 ) = %d\n"... (0737B30h) 00731939 call _printf (0731343h) 0073193E add esp,8 return 0; 00731941 xor eax,eax } 00731943 pop edi 00731944 pop esi 00731945 pop ebx 00731946 add esp,0CCh 0073194C cmp ebp,esp 0073194E call __RTC_CheckEsp (0731122h) 00731953 mov esp,ebp 00731955 pop ebp 00731956 ret
Lors de l’appel à la fonction on remarque bien que les paramètres sont passés dans les registres :
0073191E mov edx,3 00731923 mov ecx,5 00731928 call test_fastcall (073113Bh) 0073192D mov dword ptr [x],eax
En bleu nous pouvons voir le passage des arguments ; en vert nous avons l’appel de la fonction. En rouge, le retour de la fonction stocké dans le registre est transféré dans l’emplacement mémoire de la variable « x »…
Appel aux fonctions membres de classe ou thiscall
Cette convention d’appel s’applique aux fonctions membres non-statiques. Il y a deux variantes du thiscall selon le compilateur et si la fonction a un nombre d’arguments variable :
- pour gcc, par exemple, thiscall est équivalent à cdecl : l’appelant empile les arguments de droite à gauche et ajoute le pointeur this (l’instance de l’objet) comme si c’était le premier argument de la fonction.
- pour Visual C++, le pointeur sur l’instance de l’objet est passé dans le registre ECX/RCX (suivant que l’on est en 32 ou 64 bits). Pour une fonction ayant un nombre fixe d’arguments, on se base sur la convention stdcall. Dans le cas d’un nombre variable d’arguments on se base sur la convention cdecl.
Prenons comme exemple :
struct A { int m_a; A(int value) : m_a(value) { } int add(int x) { m_a += x; return m_a; } }; int main( int argc, char* argv[] ) { // thiscall A a(5); int x = a.add(3); printf("test_thiscall = %d\n", x); return 0; }
Le code généré pour la fonction A::add est le suivant sous Visual C++ 2019 :
int add(int x) { 005917A0 push ebp 005917A1 mov ebp,esp 005917A3 sub esp,0CCh 005917A9 push ebx 005917AA push esi 005917AB push edi 005917AC push ecx 005917AD lea edi,[ebp-0CCh] 005917B3 mov ecx,33h 005917B8 mov eax,0CCCCCCCCh 005917BD rep stos dword ptr es:[edi] 005917BF pop ecx 005917C0 mov dword ptr [this],ecx m_a += x; 005917C3 mov eax,dword ptr [this] 005917C6 mov ecx,dword ptr [eax] 005917C8 add ecx,dword ptr [x] 005917CB mov edx,dword ptr [this] 005917CE mov dword ptr [edx],ecx return m_a; 005917D0 mov eax,dword ptr [this] 005917D3 mov eax,dword ptr [eax] } 005917D5 pop edi 005917D6 pop esi 005917D7 pop ebx 005917D8 mov esp,ebp 005917DA pop ebp 005917DB ret 4
Et celui de la fonction main :
int main( int argc, char* argv[] ) { 005919B0 push ebp 005919B1 mov ebp,esp 005919B3 sub esp,0DCh 005919B9 push ebx 005919BA push esi 005919BB push edi 005919BC lea edi,[ebp-0DCh] 005919C2 mov ecx,37h 005919C7 mov eax,0CCCCCCCCh 005919CC rep stos dword ptr es:[edi] 005919CE mov eax,dword ptr [__security_cookie (059A000h)] 005919D3 xor eax,ebp 005919D5 mov dword ptr [ebp-4],eax // thiscall A a(5); 005919D8 push 5 005919DA lea ecx,[a] 005919DD call A::A (0591073h) int x = a.add(3); 005919E2 push 3 005919E4 lea ecx,[a] 005919E7 call A::add (059123Ah) 005919EC mov dword ptr [x],eax printf("test_thiscall = %d\n", x); 005919EF mov eax,dword ptr [x] 005919F2 push eax 005919F3 push offset string "test_thiscall = %d\n" (0597B30h) 005919F8 call _printf (059134Dh) 005919FD add esp,8 return 0; 00591A00 xor eax,eax } 00591A02 push edx 00591A03 mov ecx,ebp 00591A05 push eax 00591A06 lea edx,ds:[591A34h] 00591A0C call @_RTC_CheckStackVars@8 (0591280h) 00591A11 pop eax 00591A12 pop edx 00591A13 pop edi 00591A14 pop esi 00591A15 pop ebx 00591A16 mov ecx,dword ptr [ebp-4] 00591A19 xor ecx,ebp 00591A1B call @__security_check_cookie@4 (0591294h) 00591A20 add esp,0DCh 00591A26 cmp ebp,esp 00591A28 call __RTC_CheckEsp (0591127h) 00591A2D mov esp,ebp 00591A2F pop ebp 00591A30 ret
On remarque que l’appel au constructeur se fait via les instructions suivantes :
005919D8 push 5 # on empile le paramètre « value » 005919DA lea ecx,[a] # on stocke dans le registre ECX l’adresse de l’instance ‘a’ 005919DD call A::A (0591073h) # on appelle la fonction
De même pour l’appel à la fonction à a.add( 3 ) :
005919E2 push 3 # on empile le paramètre « x » (de valeur 3) 005919E4 lea ecx,[a] # on stocke dans le registre ECX l’adresse de l’instance ‘a’ 005919E7 call A::add (059123Ah) # on appelle la fonction 005919EC mov dword ptr [x],eax # on stocke dans la variable ‘x’ le résultat de la fonction
Ce qu’il faut retenir de thiscall est que l’on passe systématiquement un argument supplémentaire : le pointeur de l’instance. Que ce soit en l’empilant ou en le passant dans un registre du processeur cela va rajouter des instructions qui auront un impact sur les performances si la fonction est appelée un grand nombre de fois. Si une fonction de l’objet n’a pas besoin d’accéder aux variables membres de celui-ci, il est plus performant de la déclarer comme fonction statique de la classe !
Le cas des fonctions virtuelles
La programmation orientée objet nous a apporté un moyen de spécialiser les objets en diminuant le coût d’écriture grâce aux fonctions virtuelles… Cependant la gestion de ces fonctions virtuelles induit un coût supplémentaire à l’appel tout en respectant la convention d’appel thiscall. Associé à chaque classe contenant des fonctions virtuelles, le compilateur crée une table (la VTABLE) qui va contenir les adresses des fonctions virtuelles (pour le compilateur chaque fonction est un index dans le tableau). Donc, lors d’un appel à une fonction virtuelle d’un objet, il y a :
- accès au type de l’objet (ce sont des données statiques associées à la classe)
- récupérer la VTABLE
- récupérer l’adresse de la fonction virtuelle
- appeler la fonction
Prenons un exemple :
struct VA { int m_x; VA( int x ) : m_x( x ) {} virtual ~VA() {} virtual int v_op(int y) { m_x += y; return m_x; } }; struct VB : public VA { VB() : VA(0) {} virtual ~VB() {} virtual int v_op(int y) { m_x -= y; return m_x; } }; int main( int argc, char* argv[] ) { // thiscall VA *pVA = new VB(); int x = pVA->v_op(3); printf("pVA->v_op(3) = %d\n", x); return 0; }
Voici le code généré par pVA->v_op(3) :
int x = pVA->v_op(3); 00851E38 push 3 00851E3A mov eax,dword ptr [pVA] 00851E3D mov edx,dword ptr [eax] 00851E3F mov ecx,dword ptr [pVA] 00851E42 mov eax,dword ptr [edx+4] 00851E45 call eax 00851E4E mov dword ptr [x],eax
En bleu, on prépare l’appel en de la fonction en empilant le paramètre. En vert, on recherche l’adresse de la fonction virtuelle. En rouge on déclenche l’appel et en brun on récupère le résultat.
L’accès à une fonction virtuelle coûte donc 4 actions de MOV. C’est une raison pour laquelle certains experts de l’optimisation transforment l’héritage grâce à des templates et de la métaprogrammation mais ce sera l’occasion d’un autre article.
Conclusion
J’espère avoir réussi à démystifier les principales conventions d’appel de fonctions et ce que génère les compilateurs. Donc :
- les fonctions inline permettent d’optimiser la génération du code dans le cas de petites fonctions mais peut augmenter la taille du binaire
- les fonctions fastcall permettent d’accélérer les appels de fonctions ayant un ou deux arguments
- stdcall est à préférer dès lors que le code doit être accessible depuis un autre langage
- cdecl est le mode par défaut du C…
- les fonctions virtuelles sont certes pratiques mais abaissent les performances à l’exécution