Decompilar punteros a función: calli, delegate* y dónde se esconde la convención de llamada
Un delegate* no es un delegado: no hay objeto, no hay método Invoke, no hay asignación de memoria. Compila a una dirección de código en crudo apilada en la pila y a una instrucción calli que llama a través de ella. Así que un decompilador no tiene nada en lo que apoyarse: ni nombre de tipo, ni método que seguir, solo una llamada indirecta y una firma independiente que debe leer para conocer los tipos de los argumentos y la convención de llamada. Y la convención —gestionada, Cdecl, Stdcall, Thiscall— no es una palabra clave en el IL; se codifica como tipos modopt atornillados a la firma. Aquí tienes exactamente cómo se dispone un puntero a función, cómo calli nombra su propia firma, dónde vive realmente la convención no gestionada y cómo Glass.NET reconstruye el delegate* que escribiste.
Los punteros a función son el único lugar del C# moderno donde el lenguaje te entrega algo que el runtime siempre ha tenido pero casi nunca ha expuesto: una llamada a través de una dirección de código en crudo, sin ningún objeto a la vista. delegate*<int, int> parece un delegado y no se le parece en nada. No hay MulticastDelegate, no hay Invoke, no hay asignación de memoria, no hay objeto de destino: solo un entero del tamaño nativo que contiene una dirección y una única instrucción IL, calli, que llama a través de ella. Eso es exactamente lo que lo convierte en un problema de decompilación difícil: un decompilador que lee una llamada normal sigue un token hasta un método con nombre; al leer un calli, no tiene ningún token que seguir, solo una firma independiente que debe decodificar para recuperar los tipos y la convención de llamada.
Y la convención de llamada es la parte más afilada. delegate* unmanaged[Cdecl]<int, int> tiene que sobrevivir a la compilación, pero no hay ninguna palabra clave «Cdecl» en la firma: la convención se cuela como tipos modopt soldados al tipo de retorno. Un decompilador que no sabe que debe buscarlos te muestra un puntero unmanaged sin idea de si es Cdecl o Stdcall, que es la diferencia entre código que se ejecuta y código que corrompe la pila. Este es un recorrido práctico de cómo se dispone un puntero a función, qué nombra calli, dónde vive realmente la convención y cómo Glass.NET reconstruye el delegate* que escribiste.
El código fuente
Tres punteros a función: uno gestionado, uno no gestionado Cdecl y un sitio de llamada que invoca a través de un puntero gestionado cargado desde un método estático.
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
}
}
Qué es realmente calli
Compila Apply y mira el cuerpo. La llamada a op(x) no es un call ni un callvirt: esos toman un token de método que nombra un destino. Es calli, que toma un token StandAloneSig: una firma que no pertenece a ningún método ni tipo, que describe la llamada en sí.
.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
}
Lee calli int32(int32) como: «llama a través de la dirección en la cima de la pila de evaluación, tratando el valor de debajo como un argumento int32 y el resultado como int32». El tipo del parámetro en la firma del método IL, method int32 *(int32), es como se escribe un parámetro de puntero a función en los metadatos: method <ret> *(<args>). No hay ningún MethodDef ni MemberRef en ninguna parte de esta llamada: la única descripción de lo que se invoca es esa firma independiente. Ese es todo el desafío —y toda la clave— para decompilarlo.
Dónde se esconde la convención de llamada
El caso gestionado es el fácil: el byte inicial de convención de llamada de la firma dice default (gestionado) y la forma es int32(int32). El caso no gestionado es donde los decompiladores se ganan el sueldo. Mira 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
}
Dos cosas codifican la convención, y solo una de ellas es un indicador real. La firma lleva un bit de convención de llamada unmanaged, pero eso solo dice «esta es una llamada no gestionada», no qué ABI. La convención concreta es el modopt —un modificador opcional— que nombra un tipo sintético del framework: System.Runtime.CompilerServices.CallConvCdecl. Hay un tipo CallConv* para cada convención: CallConvCdecl, CallConvStdcall, CallConvThiscall, CallConvFastcall y marcadores combinables como CallConvSuppressGCTransition. El unmanaged[Cdecl] de C# compila al bit unmanaged más un modopt CallConvCdecl sobre el tipo de retorno; unmanaged[Cdecl, SuppressGCTransition] compila a dos modopt. Para recuperar el código fuente, un decompilador debe recoger cada modopt CallConv* del tipo de retorno de la firma y asociar cada uno a su nombre corto: no hay un único campo que simplemente diga «Cdecl».
Qué muestra un decompilador ingenuo
Un decompilador que maneja calli pero ignora la cadena de modopt acierta la forma y falla la convención, o descarta por completo la sintaxis de puntero a función y muestra el primitivo en crudo, una llamada parecida a IntPtr que no sabe nombrar:
// 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
Descartar [Cdecl] no es cosmético. La convención de llamada determina quién limpia la pila y cómo se pasan los argumentos; un delegate* unmanaged<...> con la convención equivocada (o ausente) recompilado contra una biblioteca nativa que espera Cdecl desajustará la ABI y corromperá la pila en la llamada. La convención es estructural, y vive solo en esos modopt.
Qué reconstruye Glass
Glass está construido sobre ICSharpCode.Decompiler, el motor de ILSpy, que decodifica la firma independiente del calli y la cadena de modopt CallConv* y reconstruye los tipos de puntero a función exactamente:
// 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);
}
Vale la pena nombrar tres recuperaciones. El puntero gestionado vuelve como delegate*<int, int> a partir de la firma independiente de convención por defecto. El puntero no gestionado vuelve como delegate* unmanaged[Cdecl]<double, double> porque Glass leyó el modopt CallConvCdecl, no solo el bit unmanaged. Y en Demo, el ldftn Double que cargó la dirección del método se reconstruye como el operador de dirección de C# &Double: el único caso en el que el decompilador sí puede nombrar a qué apunta un puntero a función, porque el destino se tomó estáticamente ahí mismo en el IL.
Qué nunca te dirá un puntero a función
Ese último punto es también el límite. La firma independiente es un metadato estático, así que Glass siempre recupera la forma y la convención de un puntero a función. Pero el valor —qué método contiene realmente el puntero— es un dato de tiempo de ejecución. Cuando se carga mediante un ldftn cercano, como en Demo, el decompilador puede seguirlo y mostrar &Double. Cuando el puntero llega como parámetro (como en Apply y CallCdecl) o se entrega desde código nativo, su destino es la dirección que fluyó en tiempo de ejecución, y ningún análisis estático puede nombrarlo, exactamente igual que el destino de un puntero a función de C es incognoscible hasta que el programa se ejecuta.
- La firma siempre se decompila. Los tipos de parámetros, el tipo de retorno y la convención de llamada están horneados en el sitio del
calli; Glass los recupera siempre. - El destino se decompila solo cuando se carga localmente. Un
ldftna la vista se convierte en&Método; un puntero pasado como argumento sigue siendo una dirección cuyo destino es un hecho de tiempo de ejecución. - Baja al IL para confirmar la convención. Cuando la ABI de un puntero no gestionado importa, la cadena de
modoptsobre la firma independiente es la verdad fundamental: léela en la vista de IL y verás cadaCallConv*que emitió el compilador.
Lee el C# para recuperar los tipos delegate* con sus convenciones intactas; baja al IL para ver cómo calli llama a través de una dirección que ningún token nombra: la única llamada de .NET donde el decompilador puede decirte la forma de lo que se llama y, en general, no qué es.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.