C#-Events dekompilieren: die CompareExchange-Schleife hinter += und -=
Eine Zeile C# — public event EventHandler Changed; — wird in der Assembly zu vier Dingen: einem privaten Delegatenfeld, einem add_Changed- und einem remove_Changed-Accessor sowie einer Zeile in der Event-Tabelle, die sie über MethodSemantics zusammenbindet. Die Accessoren sind auch keine einfachen Zuweisungen: Seit C# 4 emittiert der Compiler eine lock-freie Interlocked.CompareExchange-Schleife um Delegate.Combine und Delegate.Remove, und ältere Binärdateien tragen stattdessen eine [MethodImpl(Synchronized)]-Variante. Ein Decompiler muss beide Formen erkennen, um die vier Teile wieder zu einer Deklaration zu falten, ein explizites add/remove beizubehalten, wenn der Autor eines geschrieben hat, und die ?.Invoke-Auslösestelle statt des dup/brtrue-Tanzes darzustellen, zu dem sie kompiliert. Hier sind die Metadaten, die IL und das, was Glass.NET daraus macht.
public event EventHandler Changed; ist eine der kürzesten Deklarationen in C# und eine der am stärksten entzuckerten. Die CLR kennt kein Konzept von event als Speicherort. Was sie hat, ist eine Event-Metadatenzeile, die einen Delegatentyp benennt und auf zwei Methoden zeigt, sowie eine Konvention, die der C#-Compiler darum herum ausfüllt: ein verborgenes Feld, das den Delegaten hält, einen Accessor, der einen Handler hinzufügt, und einen Accessor, der einen entfernt. Interessant wird es bei den Accessor-Rümpfen, denn der Compiler hat seine Meinung darüber, wie sie zu schreiben sind, geändert, und beide Versionen sind in freier Wildbahn anzutreffen.
Ein Decompiler muss vier Metadaten-Entitäten wieder zu der einen Zeile falten, die Sie geschrieben haben, und das kann er nur, wenn er die Accessor-Rümpfe als die des Compilers erkennt und nicht als Ihre. Macht er das falsch, zeigt die Ausgabe entweder ein Feld plus zwei Methoden mit einer merkwürdigen Schleife darin oder — schlimmer — kollabiert ein handgeschriebenes add/remove-Paar, das etwas Wichtiges tut, zu einem Einzeiler, der es verbirgt.
Vier Metadaten-Bestandteile
Kompilieren Sie dies:
public sealed class Counter
{
public event EventHandler Changed;
}
und das Modul enthält Folgendes, was ildasm in einer Form zeigt, die die Struktur bereits andeutet:
.field private class [System.Runtime]System.EventHandler Changed
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.method public hidebysig specialname instance void
add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed { ... }
.method public hidebysig specialname instance void
remove_Changed(class [System.Runtime]System.EventHandler 'value') cil managed { ... }
.event [System.Runtime]System.EventHandler Changed
{
.addon instance void Counter::add_Changed(class [System.Runtime]System.EventHandler)
.removeon instance void Counter::remove_Changed(class [System.Runtime]System.EventHandler)
}
In den Metadatentabellen ist das eine Zeile in Event (0x14) mit dem Namen und dem Delegaten-TypeDefOrRef, eine Zeile in EventMap (0x12), die den besitzenden Typ mit seiner Folge von Events verknüpft, und zwei Zeilen in MethodSemantics (0x18), deren Semantics-Flags AddOn (0x0008) und RemoveOn (0x0010) lauten und die jeweils auf eine Accessor-Methode zeigen. Das Feld ist eine gewöhnliche Field-Zeile — nichts in den Metadaten bindet es an das Event außer seinem Namen und der Tatsache, dass die Accessoren es verwenden.
Der Accessor-Rumpf seit C# 4: eine lock-freie Schleife
Hier ist das vollständige add_Changed, wie es der aktuelle Compiler emittiert:
.method public hidebysig specialname instance void
add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.maxstack 3
.locals init (class [System.Runtime]System.EventHandler V_0, // current
class [System.Runtime]System.EventHandler V_1, // snapshot
class [System.Runtime]System.EventHandler V_2) // combined
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.EventHandler Counter::Changed
IL_0006: stloc.0 // current = this.Changed
IL_0007: ldloc.0
IL_0008: stloc.1 // snapshot = current
IL_0009: ldloc.1
IL_000a: ldarg.1
IL_000b: call class [System.Runtime]System.Delegate
[System.Runtime]System.Delegate::Combine(class Delegate, class Delegate)
IL_0010: castclass [System.Runtime]System.EventHandler
IL_0015: stloc.2 // combined = snapshot + value
IL_0016: ldarg.0
IL_0017: ldflda class [System.Runtime]System.EventHandler Counter::Changed
IL_001c: ldloc.2
IL_001d: ldloc.1
IL_001e: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange
<class [System.Runtime]System.EventHandler>(!!0&, !!0, !!0)
IL_0023: stloc.0 // current = CAS(ref field, combined, snapshot)
IL_0024: ldloc.0
IL_0025: ldloc.1
IL_0026: bne.un.s IL_0007 // if field had changed, retry
IL_0028: ret
}
Lesen Sie es als das C#, für das es steht:
void add_Changed(EventHandler value)
{
EventHandler current = Changed, snapshot;
do
{
snapshot = current;
var combined = (EventHandler)Delegate.Combine(snapshot, value);
current = Interlocked.CompareExchange(ref Changed, combined, snapshot);
}
while (current != snapshot);
}
Die Schleife existiert, weil Delegaten unveränderlich sind: Combine gibt einen neuen Multicast-Delegaten zurück, und wenn zwei Threads im selben Moment abonnieren, verliert ein naives Lesen-Kombinieren-Schreiben einen von beiden. CompareExchange veröffentlicht den neuen Delegaten nur, wenn das Feld noch den Snapshot enthält, aus dem er berechnet wurde; andernfalls gibt es den tatsächlichen Wert des Feldes zurück, und die Schleife rechnet davon ausgehend neu. remove_Changed ist Byte für Byte identisch, mit Delegate::Remove anstelle von Combine.
Beachten Sie die generische Instanziierung CompareExchange<EventHandler>(!!0&, !!0, !!0) — die !!0-Tokens sind der eigene generische Parameter der Methode. Ein Decompiler, der diesen Rumpf per Mustererkennung abgleicht, muss auf die Form matchen (ldfld, Schleife, Combine/Remove, castclass, ldflda, generisches CompareExchange, bne.un zurück zum Speichern des Snapshots) und nicht auf konkrete Tokens, da Delegatentyp, Feld und die maxstack-/Lokalvariablen-Reihenfolge der Methode allesamt variieren.
Statische Events verwenden dieselbe Schleife mit ldsfld/ldsflda und ohne ldarg.0.
Die ältere Form: [MethodImpl(Synchronized)]
Assemblies, die mit den Compilern von C# 1–3 kompiliert wurden — in langlebigen Codebasen und Drittanbieter-Referenzen noch reichlich vorhanden —, tragen einen anderen Accessor:
.method public hidebysig specialname instance void
add_Changed(class [mscorlib]System.EventHandler 'value') cil managed synchronized
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.0
IL_0002: ldfld class [mscorlib]System.EventHandler Counter::Changed
IL_0007: ldarg.1
IL_0008: call class [mscorlib]System.Delegate
[mscorlib]System.Delegate::Combine(class Delegate, class Delegate)
IL_000d: castclass [mscorlib]System.EventHandler
IL_0012: stfld class [mscorlib]System.EventHandler Counter::Changed
IL_0017: ret
}
Das Flag synchronized im Methodenkopf (MethodImplOptions.Synchronized) lässt die Laufzeit um den gesamten Rumpf herum einen Lock auf this nehmen — oder bei statischen Events auf das Typobjekt —, sodass das schlichte ldfld/Combine/stfld sicher ist. Es wurde ersetzt, weil ein Lock auf this ein öffentlicher Lock ist, um den jeder konkurrieren kann, und weil der Lock weit teurer ist als ein CAS, das fast nie wiederholt werden muss.
Beide Rümpfe bedeuten exakt dasselbe: Das ist das feldartige Event des Compilers. Ein Decompiler, der nur die moderne Schleife erkennt, zeigt das Event der älteren Assembly als Feld, zwei Methoden und ein rätselhaftes synchronized-Flag.
Auslösen: die Formen von ?.Invoke
Die Abonnementseite ist nur die halbe Geschichte. Der Autor löst das Event auch aus, und C# hatte dafür drei Idiome, jedes mit eigener IL. Das moderne:
Changed?.Invoke(this, EventArgs.Empty);
ldarg.0
ldfld class [System.Runtime]System.EventHandler Counter::Changed
dup
brtrue.s INVOKE
pop // null: discard the duplicate and skip
br.s END
INVOKE:
ldarg.0
ldsfld class [System.Runtime]System.EventArgs [System.Runtime]System.EventArgs::Empty
callvirt instance void [System.Runtime]System.EventHandler::Invoke(object, class EventArgs)
END:
Das Tripel dup/brtrue/pop ist der Fingerabdruck des Null-Conditional-Operators: einmal laden, die Kopie prüfen, das Original verwenden. Das Idiom vor C# 6 liest das Feld in eine lokale Variable und prüft diese — ein stloc/ldloc/brfalse statt dup/brtrue/pop — und ist semantisch identisch. Die dritte Form ist die ganz ohne Prüfung:
Changed(this, EventArgs.Empty); // throws if nobody subscribed
also ein nacktes ldfld + callvirt Invoke. Ein Decompiler sollte jede dieser Formen so darstellen, wie sie geschrieben wurde, denn der Unterschied ist Information: Die nackte Form in einer dekompilierten Abhängigkeit ist eine NullReferenceException, die auf einen Aufruf ohne Abonnenten wartet, und die Form mit lokaler Kopie in einer modernen Codebasis verrät Ihnen, dass die Datei älter als C# 6 ist. Alle drei zu ?.Invoke zu falten, wäre ordentlicher und falsch.
Wenn der Autor die Accessoren geschrieben hat
Die Faltungsregel hat eine Voraussetzung: Die Accessoren müssen die des Compilers sein. Klassen, die viele Events anbieten — WinForms-Controls sind der klassische Fall —, vermeiden ein Feld pro Event, indem sie Abonnenten in einer EventHandlerList halten, die über ein statisches Objekt als Schlüssel adressiert wird:
private static readonly object ChangedKey = new();
public event EventHandler Changed
{
add => Events.AddHandler(ChangedKey, value);
remove => Events.RemoveHandler(ChangedKey, value);
}
Es gibt kein Changed-Feld, kein [CompilerGenerated] auf den Accessoren, und die Rümpfe sind ein ldsfld ChangedKey / ldarg.1 / callvirt EventHandlerList::AddHandler. Die Event-Zeile und die MethodSemantics-Zeilen sehen genauso aus wie im feldartigen Fall — die Metadaten können Ihnen tatsächlich nicht sagen, wer die Accessoren geschrieben hat. Nur der Rumpf kann das. Ein Decompiler, der etwas anderes als die beiden bekannten Formen sieht, muss add { … } / remove { … } wortwörtlich emittieren, denn was auch immer darin steht — ein Schlüssel-Lookup, ein weiterleitender Aufruf, eine Weak-Reference-Registrierung — ist Verhalten, das der Autor gewählt hat.
Zwei weitere Varianten vervollständigen die Familie. Eine explizite Schnittstellenimplementierung eines Events hat Accessoren namens IObservable.add_Changed, auf die das .addon der .event-Zeile zeigt, sowie eine .override-Direktive, und muss als event EventHandler IObservable.Changed { add … remove … } dargestellt werden — explizite Implementierungen können nicht feldartig sein. Ein abstraktes oder Schnittstellen-Event hat eine Event-Zeile und Accessor-Deklarationen ganz ohne Rümpfe und wird als event EventHandler Changed; mit dem Modifizierer abstract dargestellt — derselbe Text wie bei einem feldartigen Event, nur unterscheidbar durch das fehlende Feld und das abstract-Flag auf den Accessoren.
Was Glass.NET ausgibt
Setzen Sie die Erkennungsregeln zusammen, und die Pflicht bei einem feldartigen Event ist klar. Wer auf der Metallebene stehen bleibt, zeigt dies:
// Wrong: four entities rendered literally
[CompilerGenerated] private EventHandler Changed;
[CompilerGenerated]
public void add_Changed(EventHandler value)
{
EventHandler current = Changed, snapshot;
do { snapshot = current;
current = Interlocked.CompareExchange(ref Changed,
(EventHandler)Delegate.Combine(snapshot, value), snapshot); }
while (current != snapshot);
}
// ... remove_Changed, and an event declaration that references both
während das Wiederherstellen der Absicht des Autors Folgendes ergibt:
// Right: the line the author wrote
public event EventHandler Changed;
Glass.NET erkennt sowohl die lock-freie Schleife als auch die synchronized-Form als feldartig, faltet das Feld, die Accessoren und die Event-Zeile zu einer Deklaration und behält handgeschriebene add/remove-Rümpfe exakt so bei, wie sie geschrieben wurden — einschließlich des EventHandlerList-Musters, expliziter Schnittstellen-Events und abstrakter Events. An der Auslösestelle bewahrt es, welches der drei Idiome der Code verwendet hat, sodass ein ?.Invoke, eine Prüfung mit lokaler Kopie oder ein nackter Aufruf jeweils als sie selbst zurückgelesen werden. Das Schlüsselwort event war schon immer eine Quellcode-Fiktion über einer Event-Zeile und zwei Methoden; die Aufgabe besteht darin, die Fiktion wiederherzustellen, ohne den einen Fall zu verlieren, in dem die Methoden keine Fiktion waren.
Siehe Metadatentabellen erklärt für die Tabellen Event, EventMap und MethodSemantics, die hier gelesen werden, und Lambdas und Closures für die Delegateninstanzen, die üblicherweise auf der anderen Seite von += landen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.