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.
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
callisite; Glass recovers them every time. -
The target decompiles only when it is loaded locally. A
ldftnin 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
modoptchain on the standalone signature is the ground truth — read it in the IL view and you see everyCallConv*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.
Top comments (0)