Qu'est-ce qu'une convention d'appel?

Ce document porte sur des détails spécifiques aux diverses saveurs de la plateforme Microsoft Windows et du compilateur C++ de Microsoft. Vous pouvez sûrement en adapter des parties à d'autres fins, mais il vous faudra pour cela travailler un peu par vous-mêmes.

Aussi, si les idées de pile (au sens de pile d'exécution d'un thread), de registres et de passage de paramètres vous échappe, il est possible que cet article vous semble un peu opaque. Vous pouvez examiner ces vieux documents pour vous aider à vous orienter, mais ils sont probablement tous destinés à être révisés (le temps me manque...) :

Dans certains de mes cours (en particulier ceux impliquant de la multiprogrammation sous Win32), le mot __stdcall apparaît à l'occasion. En particulier, la signature d'une fonction nommée ze_thread() susceptible de servir de thread dans l'API Win32 va comme suit (notez le mot __stdcall) :

unsigned long __stdcall ze_thread(void *);

De même, à la compilation d'un programme comme celui-ci :

int f(); // prototype, pas de définition
int main()
{
   f();
}

...on aura le message d'erreur suivant (notez le mot __cdecl) :

error LNK2019: symbole externe non résolu "int __cdecl f(void)" (?f@@YAHXZ) référencé dans la fonction _main

Il est possible que ce message d'erreur soulève d'autres questions dans votre esprit (voir ceci pour plus d'explications)..

La question suivante m'est souvent posée: que veulent dire ce __stdcall ou ce __cdecl qui se trouvent placés avant les noms de fonctions?

Tout d'abord, sachez qu'on parle ici d'un élément d'information tout à fait spécifique au compilateur C++ de Microsoft (même si rien n'empêche d'autres compilateurs de faire de même). L'idée est d'indiquer la convention d'appel applicable lors de l'invocation du sous-programme auquel elle est apposée

Par convention d'appel d'un sous-programme, on entend la manière dont sont réalisés les appels à ce sous-programme. En particulier, lors d'un appel de sous-programme :

Chaque stratégie a un certain sens, dans la mesure où l'appelant et l'appelé sont d'accord sur les règles à appliquer (il ne faut pas que l'un utilise une stratégie alors que l'autre ne la respecte pas). Le compilateur doit générer le code lors des appels en concordance avec la spécification de convention d'appel de l'appelé, évidemment.

Une fonction variadique est une fonction à nombre variable de paramètres. Les cas classiques en langage C sont les fonctions std::printf() et std::scanf() de même que leurs multiples cousines. L'existence d'un nombre variable de paramètres est indiqué par une ellipse (...) en tant que dernier « paramètre » dans sa signature.

Le compilateur ne peut pas valider les types et le nombre de paramètres lorsqu'il rencontre une telle fonction; le recours à une fonction variadique fragilise donc les programmes. Les fonctions variadiques sont rares en C++ du fait qu'il existe une multitude de stratégies alternatives et plus sécuritaires d'en arriver au même résultat. Notez que le recours à des stratégies du genre fragilise aussi Java et les langages .NET qui ont (tristement) choisi d'y avoir recours.

Le recours à la convention dite standard de l'API Win32 pour un sous-programme donné est indiqué par l'apposition du mot __stdcall dans la signature de ce sous-programme. En général, c'est la convention à utiliser pour du code susceptible d'être invoqué à partir de plusieurs langages de programmation distincts sur la plateforme Win32. Si je ne m'abuse, cette convention correspond à la convention du langage Pascal et ça ne permet pas les fonctions C dites variadiques. Les paramètres sont poussés sur la pile du dernier au premier, et l'appelé est responsable de nettoyer la pile après l'appel

La convention d'appel utilisée par défaut avec C et avec C++ (entre autres) est __cdecl. Les paramètres sont poussés sur la pile du dernier au premier, ce qui permet des fonctions variadiques – en effet, il faut que la chaîne de formatage de std::printf() ou de std::scanf() soit sur le dessus de la pile lorsque la fonction s'exécute pour qu'elle puisse être débusquée par le sous-programme appelé. L'appelant est responsable de nettoyer la pile après l'appel, car lui seul connaît alors l'état de la pile.

La convention d'appel __clrcall charge les paramètres dans la pile d'expressions du CLR, de gauche à droite. Cette convention joue un rôle dans l'appel par un programme C++ de code .NET.

Si la question vous intéresse, je vous invite à lire ce texte de 2005 par Larry Osterman à propos de l'impact de __fastcall sur un appel de méthode d'instance, et sur le travail qu'il lui a fallu pour cerner cet impact.

La convention d'appel __fastcall utilise les registres pour passer les paramètres. Évidemment, il faut que les paramètres puissent entrer dans les registres. À vous de voir s'il vaut la peine d'avoir recours à cette convention d'appel dans vos programmes, et de faire des tests pour voir si le jeu en vaut la chandelle. À mon avis, le inlining devrait donner de meilleurs résultats. L'appelé est chargé de nettoyer la pile après l'appel.

La convention __thiscall d'une méthode d'instance est un autre dossier et signifie simplement le passage implicite de this comme premier paramètre (caché) lors de l'invocation de la méthode. Cette convention est implicite pour les méthodes d'instance et se combine à l'une des trois autres. Pour les curieuses et les curieux, le registre ECX est utilisé pour véhiculer this.

Depuis Visual Studio 2013, la convention d'appel __vectorcall est possible sur certains processeurs. Son rôle est de permettre le passage de paramètres dans des registres vectoriels si le matériel le permet. Pour plus d'explications, voir ce texte d'Ankit Athana en 2013 : http://blogs.msdn.com/b/vcblog/archive/2013/07/12/introducing-vector-calling-convention.aspx

Risques et périls

Soyez toujours prudentes et prudents avec les exemples de code pris dans Internet ou dans MSDN. Certains ont visiblement été rédigés un peu trop rapidement ou n'ont pas été testés suffisamment, même s'ils semblent inoffensifs à première vue (même pour des experts).

L'exemple qui suit est tiré d'une expérience réelle. Les noms ont été changés, mais le code source (simplifié à l'extrême dans cet article pour les besoins de la cause) vient d'une compagnie très connue. Vous trouverez un autre exemple semblable sur http://blogs.msdn.com/b/oldnewthing/archive/2011/05/06/10161590.aspx si la question vous intéresse.

Imaginons une API dans laquelle on trouve le type pf, représentant un pointeur de fonction prenant une adresse pure (un void*) en paramètre et retournant un long. Un pf doit pointer vers une fonction de convention d'appel __stdcall.

typedef long (__stdcall *pf) (void*);

La fonction invoquer() reçoit en paramètre un pf, nommé f localement, et invoque f() en lui passant un pointeur nul, ce qui est tout à fait légal sur le plan syntaxique et sur le plan sémantique.

void invoquer(pf f)
{
   long l = f(0);
}

Ailleurs dans le programme, on trouve une fonction fct() dont la signature n'est pas conforme à celle du type pf. En effet, la fonction fct() est void (c'est une procédure au sens strict) et ne prend pas de paramètres.

void fct()
{
   int i = 3;
}

Enfin, le programme principal cherche à invoquer fct() à travers la fonction invoquer(). Constatant que fct() ne respecte pas la signature attendue d'un pf, une simple conversion explicite de types du langage C (type de destination placé entre parenthèses) est utilisée pour mentir au compilateur et lui faire croire que le type de fct est conforme à celui décrit par pf.

int main()
{
   invoquer((pf)fct);
}

Résultat : une explosion à l'exécution. Cette manoeuvre déstabilise la pile d'exécution du programme puisque fct() est une fonction C classique (__cdecl), où les responsabilités de l'appelant et de l'appelé pour la gestion de la pile suivent la convention établie pour le langage C, alors que pf décrit une fonction __stdcall, qui respecte la convention d'appel du langage Pascal pour laquelle les règles portant sur le partage des responsabilités quant à la gestion de la pile d'exécution sont différentes.

La fonction fct() se sait être une fonction C conventionnelle. Elle ne peut pas savoir qu'elle a été invoquée par invoquer() selon une autre convention d'appel. De même, invoquer() ne peut pas savoir que la fonction qu'elle invoque ne respecte pas la convention d'appel Pascal puisque main() lui a menti en lui passant l'adresse de la fonction fct() qui ne s'y conforme pas.

À retenir :

  • Ne pas mentir sur les conventions d'appel des fonctions
  • Ne pas utiliser de conversions explicites de types du langage C. Jamais. Préférez systématiquement les conversions explicites de types ISO qui sont plus claires et laissent moins de place aux accidents
  • Si vous devez invoquer une fonction ayant une convention d'appel particulière à travers une API qui ne s'y attend pas, envisagez une stratégie reposant sur l'enrobage, comme dans l'exemple proposé à droite
typedef long (__stdcall *pf)(void*);
void invoquer(pf f)
{
   long l = f(0);
}
void fct()
{
   int i = 3;
}
//
// enrober l'appel à fct dans un pf
//
long __stdcall enrobage(void *)
{
   fct();
   return 0;
}
int main()
{
   invoquer(enrobage);
}

À propos des expressions λ

Le standard C++ 11 dicte qu'une expression λ doit pouvoir être implicitement convertible en pointeur de fonction, dans la mesure où son bloc de capture (ce qui se trouve dans []) est vide – si le bloc de capture n'est pas vide, alors la λ est nécessairement un foncteur.

Dans le monde Windows, cela soulève la question de la convention d'appel du pointeur résultant. Par exemple, un appel tel que celui ci-dessous est-il légal?

#include <iostream>
#include <windows.h>
int main()
{
   auto h = CreateThread(0, 0,[](void *) -> unsigned long {
      using namespace std;
      Sleep(2000); // attendre deux secondes
      cout << "Coucou!" << endl;
      return 0;
   }, 0, 0);
   WaitForSingleObject(g);
   CloseHandle(h);
}

Pour bien saisir la question, il faut comprendre ici que le pointeur de fonction passé à CreateThread() en tant que troisième paramètre doit mener vers une fonction ayant la signature suivante :

unsigned long __stdcall(void*)

donc la capacité de faire cette manoeuvre dépend de la capacité d'une λ de se convertir en pointeur de fonction respectant la convention d'appel exigée par l'expression. N'oublions pas qu'en langage C++, par défaut, la convention d'appel est (si nous prenons la notation du compilateur de Microsoft) __cdecl et non pas __stdcall.

Heureusement, c'est bel et bien le cas : le compilateur (du moins celui de Microsoft) prend alors en charge la conversion implicite d'une λ en pointeur de fonction de la convention d'appel demandée. Il est donc possible d'utiliser des λ en lieu de place de pointeurs de fonctions de divers types, dans la mesure où nous évitons, ce faisant, les situations qui pourraient mener à des situations ambiguëes, telles que :

int f(int (__stdcall *pf)(int), int n)
{
   return -(*pf)(n);
}
int f(int (*pf)(int), int n) // pf est implicitement __cdecl
{
   return (*pf)(n)+1;
}
int main()
{
   f([](int n) { return n * n; }, 3); // oups!
}

Ici, la λ devrait-elle être convertie en pointeur de fonction de convention d'appel __stdcall ou de convention d'appel __cdecl? Le compilateur ne peut faire ce choix pour nous, donc ce programme est ambigu. Bien sûr, ces situations sont rares, mais vieux vaut être conscient de leur existence.

Lectures complémentaires


Valid XHTML 1.0 Transitional

CSS Valide !