Funktionszeiger dekompilieren: calli, delegate* und wo sich die Aufrufkonvention versteckt
Ein delegate* ist kein Delegate – es gibt kein Objekt, keine Invoke-Methode, keine Allokation. Es kompiliert zu einer rohen Codeadresse, die auf den Stack geschoben wird, und einer calli-Anweisung, die darüber aufruft. Ein Dekompilierer hat also nichts, worauf er sich stützen könnte: keinen Typnamen, keine Methode, der er folgen kann, nur einen indirekten Aufruf und eine eigenständige Signatur, die er lesen muss, um die Argumenttypen und die Aufrufkonvention zu kennen. Und die Konvention – managed, Cdecl, Stdcall, Thiscall – ist kein Schlüsselwort im IL; sie ist als modopt-Typen codiert, die an die Signatur angebolzt sind. Hier erfahren Sie genau, wie ein Funktionszeiger abgelegt wird, wie calli seine eigene Signatur benennt, wo die unmanaged-Konvention wirklich lebt, und wie Glass.NET das delegate* rekonstruiert, das Sie geschrieben haben.
Funktionszeiger sind die eine Stelle in modernem C#, an der die Sprache Ihnen etwas in die Hand gibt, das die Laufzeit schon immer hatte, aber fast nie offengelegt hat: einen Aufruf über eine rohe Codeadresse, ohne ein Objekt in Sicht. delegate*<int, int> sieht aus wie ein Delegate und ist nichts dergleichen. Es gibt kein MulticastDelegate, kein Invoke, keine Allokation, kein Zielobjekt – nur eine native-breite Ganzzahl, die eine Adresse hält, und eine einzelne IL-Anweisung, calli, die darüber aufruft. Genau das macht es zu einem schwierigen Dekompilierungsproblem: Ein Dekompilierer, der einen normalen Aufruf liest, folgt einem Token zu einer benannten Methode; liest er ein calli, hat er kein Token, dem er folgen könnte, nur eine eigenständige Signatur, die er decodieren muss, um die Typen und die Aufrufkonvention wiederherzustellen.
Und die Aufrufkonvention ist der schärfste Teil. delegate* unmanaged[Cdecl]<int, int> muss die Kompilierung überleben, aber es gibt kein „Cdecl“-Schlüsselwort in der Signatur – die Konvention wird als modopt-Typen hineingeschmuggelt, die auf den Rückgabetyp geschweißt sind. Ein Dekompilierer, der nicht weiß, danach zu suchen, zeigt Ihnen einen unmanaged-Zeiger ohne eine Ahnung, ob er Cdecl oder Stdcall ist, was der Unterschied zwischen laufendem Code und Code ist, der den Stack korrumpiert. Dies ist eine praktische Tour durch das, wie ein Funktionszeiger abgelegt wird, was calli benennt, wo die Konvention wirklich lebt, und wie Glass.NET das delegate* rekonstruiert, das Sie geschrieben haben.
Der Quelltext
Drei Funktionszeiger: ein managed, ein unmanaged Cdecl, und eine Aufrufstelle, die über einen aus einer statischen Methode geladenen managed Zeiger aufruft.
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
}
}
Was calli eigentlich ist
Kompilieren Sie Apply und sehen Sie sich den Körper an. Der Aufruf von op(x) ist kein call oder callvirt – diese nehmen ein Methoden-Token, das ein Ziel benennt. Es ist calli, das ein StandAloneSig-Token nimmt: eine Signatur, die zu keiner Methode und keinem Typ gehört und den Aufruf selbst beschreibt.
.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
}
Lesen Sie calli int32(int32) als: „Rufe über die Adresse oben auf dem Auswertungsstack auf, behandle den Wert darunter als int32-Argument und das Ergebnis als int32.“ Der Parametertyp in der IL-Methodensignatur, method int32 *(int32), ist die Art, wie ein Funktionszeiger-Parameter in Metadaten geschrieben wird – method <ret> *(<args>). Es gibt in diesem Aufruf nirgends ein MethodDef oder MemberRef: Die einzige Beschreibung dessen, was aufgerufen wird, ist diese eigenständige Signatur. Das ist die ganze Herausforderung – und der ganze Schlüssel – beim Dekompilieren.
Wo sich die Aufrufkonvention versteckt
Der managed-Fall ist der einfache – das führende Konventions-Byte der Signatur sagt default (managed), und die Gestalt ist int32(int32). Der unmanaged-Fall ist der, bei dem Dekompilierer ihr Geld verdienen. Sehen Sie sich CallCdecl an:
.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
}
Zwei Dinge codieren die Konvention, und nur eines davon ist ein echtes Flag. Die Signatur trägt ein unmanaged-Konventions-Bit – aber das sagt nur „dies ist ein unmanaged-Aufruf“, nicht welche ABI. Die konkrete Konvention ist das modopt – ein optionaler Modifizierer –, das einen synthetischen Framework-Typ benennt: System.Runtime.CompilerServices.CallConvCdecl. Es gibt einen CallConv*-Typ für jede Konvention: CallConvCdecl, CallConvStdcall, CallConvThiscall, CallConvFastcall und komponierbare Markierungen wie CallConvSuppressGCTransition. Das C#-unmanaged[Cdecl] kompiliert zum unmanaged-Bit plus einem CallConvCdecl-modopt auf dem Rückgabetyp; unmanaged[Cdecl, SuppressGCTransition] kompiliert zu zwei modopts. Um den Quelltext wiederherzustellen, muss ein Dekompilierer jeden CallConv*-modopt auf dem Rückgabetyp der Signatur einsammeln und jeden auf seinen Kurznamen abbilden – es gibt kein einzelnes Feld, das einfach „Cdecl“ sagt.
Was ein naiver Dekompilierer zeigt
Ein Dekompilierer, der calli behandelt, aber die modopt-Kette ignoriert, bekommt die Gestalt richtig und die Konvention falsch – oder lässt die Funktionszeiger-Syntax ganz fallen und zeigt das rohe Primitiv, einen IntPtr-artigen Aufruf, den er nicht benennen kann:
// 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
[Cdecl] fallen zu lassen ist nicht kosmetisch. Die Aufrufkonvention bestimmt, wer den Stack aufräumt und wie Argumente übergeben werden; ein delegate* unmanaged<...> mit der falschen (oder fehlenden) Konvention, neu gegen eine native Bibliothek kompiliert, die Cdecl erwartet, passt nicht zur ABI und korrumpiert den Stack beim Aufruf. Die Konvention ist tragend, und sie lebt nur in diesen modopts.
Was Glass rekonstruiert
Glass ist auf ICSharpCode.Decompiler aufgebaut, der ILSpy-Engine, die die eigenständige calli-Signatur und die CallConv*-modopt-Kette decodiert und die Funktionszeiger-Typen exakt rekonstruiert:
// 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);
}
Drei Wiederherstellungen sind erwähnenswert. Der managed Zeiger kommt als delegate*<int, int> aus der eigenständigen Signatur mit Default-Konvention zurück. Der unmanaged Zeiger kommt als delegate* unmanaged[Cdecl]<double, double> zurück, weil Glass das CallConvCdecl-modopt gelesen hat, nicht nur das unmanaged-Bit. Und in Demo wird das ldftn Double, das die Adresse der Methode geladen hat, als C#-Adressoperator &Double rekonstruiert – der eine Fall, in dem der Dekompilierer benennen kann, worauf ein Funktionszeiger zeigt, weil das Ziel genau dort im IL statisch genommen wurde.
Was ein Funktionszeiger Ihnen nie verraten wird
Dieser letzte Punkt ist auch die Grenze. Die eigenständige Signatur ist statisches Metadatum, also stellt Glass immer die Gestalt und Konvention eines Funktionszeigers wieder her. Aber der Wert – welche Methode der Zeiger tatsächlich hält – ist Laufzeitdatum. Wenn er von einem nahen ldftn geladen wird, wie in Demo, kann der Dekompilierer ihm folgen und &Double zeigen. Wenn der Zeiger als Parameter ankommt (wie in Apply und CallCdecl) oder aus nativem Code übergeben wird, ist sein Ziel die Adresse, die zur Laufzeit hineingeflossen ist, und keine statische Analyse kann es benennen – genau wie das Ziel eines C-Funktionszeigers unbekannt ist, bis das Programm läuft.
- Die Signatur dekompiliert immer. Parametertypen, Rückgabetyp und Aufrufkonvention sind in die
calli-Stelle eingebacken; Glass stellt sie jedes Mal wieder her. - Das Ziel dekompiliert nur, wenn es lokal geladen wird. Ein sichtbares
ldftnwird zu&Method; ein übergebener Zeiger bleibt eine Adresse, deren Ziel ein Laufzeitfakt ist. - Zum IL hinabsteigen, um die Konvention zu bestätigen. Wenn die ABI eines unmanaged-Zeigers wichtig ist, ist die
modopt-Kette auf der eigenständigen Signatur die Grundwahrheit – lesen Sie sie in der IL-Ansicht, und Sie sehen jedesCallConv*, das der Compiler ausgegeben hat.
Lesen Sie das C#, um die delegate*-Typen mit intakten Konventionen zurückzubekommen; steigen Sie zum IL hinab, um zu sehen, wie calli über eine Adresse aufruft, die kein Token benennt – der eine Aufruf in .NET, bei dem der Dekompilierer Ihnen die Gestalt des Aufgerufenen sagen kann und, im Allgemeinen, nicht, was es ist.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.