Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilation.NETAnalyse approfondie

Décompiler les pointeurs de fonction : calli, delegate* et où se cache la convention d'appel

Un delegate* n'est pas un délégué — pas d'objet, pas de méthode Invoke, pas d'allocation. Il compile vers une adresse de code brute poussée sur la pile et une instruction calli qui appelle à travers elle. Un décompilateur n'a donc rien sur quoi s'appuyer : pas de nom de type, pas de méthode à suivre, juste un appel indirect et une signature autonome qu'il doit lire pour connaître les types d'arguments et la convention d'appel. Et la convention — managed, Cdecl, Stdcall, Thiscall — n'est pas un mot-clé dans l'IL ; elle est encodée comme des types modopt greffés sur la signature. Voici exactement comment un pointeur de fonction est posé, comment calli nomme sa propre signature, où vit réellement la convention non managée, et comment Glass.NET reconstruit le delegate* que vous avez écrit.

Les pointeurs de fonction sont le seul endroit du C# moderne où le langage vous tend quelque chose que le runtime a toujours eu mais presque jamais exposé : un appel à travers une adresse de code brute, sans aucun objet en vue. delegate*<int, int> ressemble à un délégué et n’a rien à voir avec lui. Pas de MulticastDelegate, pas d’Invoke, pas d’allocation, pas d’objet cible — juste un entier de taille native contenant une adresse et une unique instruction IL, calli, qui appelle à travers elle. C’est exactement ce qui en fait un problème de décompilation difficile : un décompilateur qui lit un appel normal suit un jeton vers une méthode nommée ; en lisant un calli, il n’a aucun jeton à suivre, seulement une signature autonome qu’il doit décoder pour retrouver les types et la convention d’appel.

Et la convention d’appel est la partie la plus tranchante. delegate* unmanaged[Cdecl]<int, int> doit survivre à la compilation, mais il n’y a aucun mot-clé « Cdecl » dans la signature — la convention y est glissée sous forme de types modopt soudés sur le type de retour. Un décompilateur qui ne sait pas les chercher vous montre un pointeur unmanaged sans aucune idée de s’il s’agit de Cdecl ou de Stdcall, ce qui fait la différence entre du code qui s’exécute et du code qui corrompt la pile. Voici une visite pratique de la façon dont un pointeur de fonction est posé, de ce que calli nomme, de l’endroit où vit réellement la convention, et de la manière dont Glass.NET reconstruit le delegate* que vous avez écrit.

La source

Trois pointeurs de fonction : un managé, un non managé Cdecl, et un site d’appel qui invoque à travers un pointeur managé chargé depuis une méthode statique.

public static unsafe class Native
{
    // A managed function pointer: no object, no Invoke.
    public static int Apply(delegate*<int, int> op, int x) => op(x);

    // An unmanaged Cdecl function pointer — the kind you hand to P/Invoke-free interop.
    public static double CallCdecl(delegate* unmanaged[Cdecl]<double, double> fn, double v)
        => fn(v);

    // Load the address of a real method into a managed pointer and call through it.
    public static int Double(int n) => n * 2;
    public static int Demo()
    {
        delegate*<int, int> p = &Double;     // ldftn Double
        return Apply(p, 21);                  // -> 42
    }
}

Ce qu’est réellement calli

Compilez Apply et regardez le corps. L’appel à op(x) n’est ni un call ni un callvirt — ceux-ci prennent un jeton de méthode nommant une cible. C’est calli, qui prend un jeton StandAloneSig : une signature qui n’appartient à aucune méthode ni aucun type, décrivant l’appel lui-même.

.method public hidebysig static int32 Apply(method int32 *(int32) op, int32 x) cil managed
{
    ldarg.1                       // x  -> the argument
    ldarg.0                       // op -> the code ADDRESS, pushed last (top of stack)
    calli      int32(int32)       // call through the address, per this standalone signature
    ret
}

Lisez calli int32(int32) ainsi : « appelle à travers l’adresse au sommet de la pile d’évaluation, en traitant la valeur en dessous comme un argument int32 et le résultat comme un int32 ». Le type de paramètre dans la signature de méthode IL, method int32 *(int32), est la façon dont un paramètre de type pointeur de fonction s’épelle en métadonnées — method <ret> *(<args>). Il n’y a aucun MethodDef ni MemberRef nulle part dans cet appel : la seule description de ce qui est invoqué est cette signature autonome. C’est tout le défi — et toute la clé — de sa décompilation.

call / callvirtjeton → MethodDef "Double"le décompilateur suit le jeton vers un nomcible connue statiquementcallijeton StandAloneSig (pas de cible !)l'adresse est sur la pile à l'exécutiondécoder la signature autonomeconvention · int32 · (int32)→ forme delegate*<int,int> retrouvée

Où se cache la convention d’appel

Le cas managé est le facile — l’octet de convention d’appel en tête de la signature dit default (managé) et la forme est int32(int32). Le cas non managé est là où les décompilateurs gagnent leur pain. Regardez CallCdecl :

.method public hidebysig static float64 CallCdecl(
    method unmanaged cdecl float64 modopt([System.Runtime]System.Runtime.CompilerServices.CallConvCdecl) *(float64) fn,
    float64 v) cil managed
{
    ldarg.1
    ldarg.0
    calli      unmanaged cdecl float64 modopt(...CallConvCdecl) (float64)
    ret
}

Deux choses encodent la convention, et une seule d’entre elles est un vrai drapeau. La signature porte un bit de convention d’appel unmanaged — mais celui-ci dit seulement « c’est un appel non managé », pas laquelle des ABI. La convention précise est le modopt — un modificateur optionnel — nommant un type synthétique du framework : System.Runtime.CompilerServices.CallConvCdecl. Il y a un type CallConv* pour chaque convention : CallConvCdecl, CallConvStdcall, CallConvThiscall, CallConvFastcall, et des marqueurs composables comme CallConvSuppressGCTransition. Le C# unmanaged[Cdecl] compile vers le bit unmanaged plus un modopt CallConvCdecl sur le type de retour ; unmanaged[Cdecl, SuppressGCTransition] compile vers deux modopts. Pour retrouver la source, un décompilateur doit collecter chaque modopt CallConv* sur le type de retour de la signature et faire correspondre chacun à son nom court — il n’y a aucun champ unique qui dise simplement « Cdecl ».

Ce qu’un décompilateur naïf montre

Un décompilateur qui gère calli mais ignore la chaîne de modopt obtient la bonne forme et la mauvaise convention — ou abandonne entièrement la syntaxe de pointeur de fonction et montre la primitive brute, un appel de type IntPtr qu’il ne peut pas nommer :

// Naive output: shape present, convention lost (or shown as bare 'unmanaged').
public unsafe static double CallCdecl(delegate* unmanaged<double, double> fn, double v)
    => fn(v);    // Cdecl dropped — this is now ambiguous and, if re-emitted, wrong

Abandonner [Cdecl] n’est pas cosmétique. La convention d’appel détermine qui nettoie la pile et comment les arguments sont passés ; un delegate* unmanaged<...> avec la mauvaise convention (ou aucune) recompilé contre une bibliothèque native qui attend Cdecl mésalignera l’ABI et corrompra la pile au point d’appel. La convention est porteuse, et elle ne vit que dans ces modopts.

Ce que Glass reconstruit

Glass est bâti sur ICSharpCode.Decompiler, le moteur d’ILSpy, qui décode la signature autonome de calli et la chaîne de modopts CallConv* et reconstruit exactement les types pointeurs de fonction :

// Glass output: shape and convention both recovered.
public unsafe static int Apply(delegate*<int, int> op, int x) => op(x);

public unsafe static double CallCdecl(delegate* unmanaged[Cdecl]<double, double> fn, double v)
    => fn(v);

public unsafe static int Demo()
{
    delegate*<int, int> p = &Double;    // ldftn recovered as the address-of operator
    return Apply(p, 21);
}

Trois récupérations méritent d’être nommées. Le pointeur managé revient sous la forme delegate*<int, int> à partir de la signature autonome à convention par défaut. Le pointeur non managé revient sous la forme delegate* unmanaged[Cdecl]<double, double> parce que Glass a lu le modopt CallConvCdecl, et pas seulement le bit unmanaged. Et dans Demo, le ldftn Double qui a chargé l’adresse de la méthode est reconstruit comme l’opérateur d’adresse C# &Double — le seul cas où le décompilateur peut nommer ce vers quoi pointe un pointeur de fonction, parce que la cible a été prise statiquement juste là dans l’IL.

Ce qu’un pointeur de fonction ne vous dira jamais

Ce dernier point est aussi la limite. La signature autonome est une métadonnée statique, donc Glass retrouve toujours la forme et la convention d’un pointeur de fonction. Mais la valeur — quelle méthode le pointeur contient réellement — est une donnée d’exécution. Quand elle est chargée par un ldftn voisin, comme dans Demo, le décompilateur peut la suivre et montrer &Double. Quand le pointeur arrive comme paramètre (comme dans Apply et CallCdecl) ou est fourni depuis du code natif, sa destination est l’adresse qui y a circulé à l’exécution, et aucune analyse statique ne peut la nommer — exactement comme la cible d’un pointeur de fonction C est inconnaissable jusqu’à ce que le programme s’exécute.

  • La signature se décompile toujours. Les types de paramètres, le type de retour et la convention d’appel sont figés dans le site calli ; Glass les retrouve à chaque fois.
  • La cible ne se décompile que lorsqu’elle est chargée localement. Un ldftn en vue devient &Method ; un pointeur passé en argument reste une adresse dont la destination est un fait d’exécution.
  • Passez à l’IL pour confirmer la convention. Quand l’ABI d’un pointeur non managé importe, la chaîne de modopt sur la signature autonome est la vérité de terrain — lisez-la dans la vue IL et vous voyez chaque CallConv* que le compilateur a émis.

Lisez le C# pour récupérer les types delegate* avec leurs conventions intactes ; passez à l’IL pour voir calli appeler à travers une adresse qu’aucun jeton ne nomme — le seul appel de .NET où le décompilateur peut vous dire la forme de ce qui est appelé et, en général, pas ce que c’est.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.