Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling function pointers: calli, delegate*, and where the calling convention hides

A delegate* is not a delegate — there is no object, no Invoke method, no allocation. It compiles to a raw code address pushed on the stack and a calli instruction that calls through it. So a decompiler has nothing to lean on: no type name, no method to follow, just an indirect call and a standalone signature it must read to know the argument types and the calling convention. And the convention — managed, Cdecl, Stdcall, Thiscall — is not a keyword in the IL; it is encoded as modopt types bolted onto the signature. Here is exactly how a function pointer is laid down, how calli names its own signature, where the unmanaged convention actually lives, and how Glass.NET rebuilds the delegate* you wrote.

Function pointers are the one place in modern C# where the language hands you something the runtime has always had but almost never exposed: a call through a raw code address, with no object in sight. delegate*<int, int> looks like a delegate and is nothing like one. There is no MulticastDelegate, no Invoke, no allocation, no target object — just a native-sized integer holding an address and a single IL instruction, calli, that calls through it. That is exactly what makes it a hard decompilation problem: a decompiler reading a normal call follows a token to a named method; reading a calli, it has no token to follow, only a standalone signature it must decode to recover the types and the calling convention.

And the calling convention is the sharpest part. delegate* unmanaged[Cdecl]<int, int> has to survive compilation, but there is no “Cdecl” keyword in the signature — the convention is smuggled in as modopt types welded onto the return type. A decompiler that does not know to look for them shows you an unmanaged pointer with no idea whether it is Cdecl or Stdcall, which is the difference between code that runs and code that corrupts the stack. This is a working tour of how a function pointer is laid down, what calli names, where the convention actually lives, and how Glass.NET rebuilds the delegate* you wrote.

The source

Three function pointers: a managed one, an unmanaged Cdecl one, and a call site that invokes through a managed pointer loaded from a static method.

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
    }
}

What calli actually is

Compile Apply and look at the body. The call to op(x) is not a call or callvirt — those take a method token naming a target. It is calli, which takes a StandAloneSig token: a signature that belongs to no method and no type, describing the call itself.

.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
}

Read calli int32(int32) as: “call through the address on top of the evaluation stack, treating the value beneath it as an int32 argument and the result as int32.” The parameter type in the IL method signature, method int32 *(int32), is how a function pointer parameter is spelled in metadata — method <ret> *(<args>). There is no MethodDef or MemberRef anywhere in this call: the only description of what is being invoked is that standalone signature. That is the whole challenge — and the whole key — to decompiling it.

call / callvirttoken → MethodDef "Double"decompiler follows the token to a nametarget known staticallycalliStandAloneSig token (no target!)address is on the stack at run timedecode the standalone signatureconvention · int32 · (int32)→ delegate*<int,int> shape recovered

Where the calling convention hides

The managed case is the easy one — the signature’s leading calling-convention byte says default (managed) and the shape is int32(int32). The unmanaged case is where decompilers earn their keep. Look at 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
}

Two things encode the convention, and only one of them is a real flag. The signature carries an unmanaged calling-convention bit — but that only says “this is an unmanaged call,” not which ABI. The specific convention is the modopt — an optional modifier — naming a synthetic framework type: System.Runtime.CompilerServices.CallConvCdecl. There is a CallConv* type for each convention: CallConvCdecl, CallConvStdcall, CallConvThiscall, CallConvFastcall, and composable markers like CallConvSuppressGCTransition. The C# unmanaged[Cdecl] compiles to the unmanaged bit plus a CallConvCdecl modopt on the return type; unmanaged[Cdecl, SuppressGCTransition] compiles to two modopts. To recover the source, a decompiler must collect every CallConv* modopt on the signature’s return type and map each back to its short name — there is no single field that just says “Cdecl.”

What a naive decompiler shows

A decompiler that handles calli but ignores the modopt chain gets the shape right and the convention wrong — or drops the function-pointer syntax entirely and shows the raw primitive, an IntPtr-like call it cannot name:

// 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

Dropping [Cdecl] is not cosmetic. The calling convention determines who cleans the stack and how arguments are passed; a delegate* unmanaged<...> with the wrong (or missing) convention re-compiled against a native library that expects Cdecl will mismatch the ABI and corrupt the stack at the call. The convention is load-bearing, and it lives only in those modopts.

What Glass reconstructs

Glass is built on ICSharpCode.Decompiler, the ILSpy engine, which decodes the calli standalone signature and the CallConv* modopt chain and rebuilds the function-pointer types exactly:

// 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);
}

Three recoveries are worth naming. The managed pointer comes back as delegate*<int, int> from the default-convention standalone signature. The unmanaged pointer comes back as delegate* unmanaged[Cdecl]<double, double> because Glass read the CallConvCdecl modopt, not just the unmanaged bit. And in Demo, the ldftn Double that loaded the method’s address is reconstructed as the C# address-of operator &Double — the one case where the decompiler can name what a function pointer points at, because the target was taken statically right there in the IL.

What a function pointer will never tell you

That last point is also the limit. The standalone signature is static metadata, so Glass always recovers the shape and convention of a function pointer. But the value — which method the pointer actually holds — is runtime data. When it is loaded by a nearby ldftn, as in Demo, the decompiler can follow it and show &Double. When the pointer arrives as a parameter (as in Apply and CallCdecl) or is handed in from native code, its destination is whatever address flowed in at run time, and no amount of static analysis can name it — exactly as a C function pointer’s target is unknowable until the program runs.

  • The signature always decompiles. Parameter types, return type, and calling convention are baked into the calli site; Glass recovers them every time.
  • The target decompiles only when it is loaded locally. A ldftn in view becomes &Method; a pointer passed in stays an address whose destination is a runtime fact.
  • Drop to IL to confirm the convention. When an unmanaged pointer’s ABI matters, the modopt chain on the standalone signature is the ground truth — read it in the IL view and you see every CallConv* the compiler emitted.

Read the C# to get the delegate* types back with their conventions intact; drop to the IL to watch calli call through an address that no token names — the one call in .NET where the decompiler can tell you the shape of the thing being called and, in general, not what it is.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.