logo le blog invivoo blanc

Appel de fonctions

6 janvier 2020 | C++ | 0 comments

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