Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Decompilar eventos de C#: el bucle CompareExchange detrás de += y -=

Una línea de C# — public event EventHandler Changed; — se convierte en cuatro cosas en el ensamblado: un campo de delegado privado, un accesor add_Changed y otro remove_Changed, y una fila de la tabla Event que los vincula mediante MethodSemantics. Los accesores tampoco son simples asignaciones: desde C# 4 el compilador emite un bucle Interlocked.CompareExchange sin bloqueos alrededor de Delegate.Combine y Delegate.Remove, y los binarios más antiguos llevan en su lugar una variante con [MethodImpl(Synchronized)]. Un decompilador tiene que reconocer ambas formas para plegar las cuatro piezas de vuelta en una sola declaración, conservar un add/remove explícito cuando el autor lo escribió y renderizar el sitio de invocación ?.Invoke en lugar del baile de dup/brtrue al que compila. Aquí tienes los metadatos, el IL y lo que Glass.NET hace con ello.

public event EventHandler Changed; es una de las declaraciones más cortas de C#, y una de las más intensamente desazucaradas. El CLR no tiene ningún concepto de event como ubicación de almacenamiento. Lo que tiene es una fila de metadatos Event que nombra un tipo de delegado y apunta a dos métodos, y una convención que el compilador de C# rellena a su alrededor: un campo oculto para guardar el delegado, un accesor que añade un manejador y un accesor que lo quita. Los cuerpos de los accesores son donde la cosa se pone interesante, porque el compilador ha cambiado de opinión sobre cómo escribirlos, y ambas versiones andan sueltas por ahí.

Un decompilador tiene que plegar cuatro entidades de metadatos de vuelta en la única línea que escribiste, y solo puede hacerlo si reconoce los cuerpos de los accesores como del compilador y no tuyos. Si se equivoca, la salida o bien te muestra un campo más dos métodos con un bucle extraño dentro, o — peor — colapsa un par add/remove escrito a mano que hace algo importante en una sola línea que lo oculta.

Cuatro piezas de metadatos

Compila esto:

public sealed class Counter
{
    public event EventHandler Changed;
}

y el módulo contiene lo siguiente, que ildasm muestra en una forma que ya insinúa la estructura:

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

En las tablas de metadatos eso es una fila en Event (0x14) que contiene el nombre y el TypeDefOrRef del delegado, una fila en EventMap (0x12) que vincula el tipo propietario con su secuencia de eventos, y dos filas en MethodSemantics (0x18) cuyos flags Semantics son AddOn (0x0008) y RemoveOn (0x0010), cada una apuntando a un método accesor. El campo es una fila ordinaria de Field — nada en los metadatos lo vincula al evento salvo su nombre y el hecho de que los accesores lo usan.

C# sourceevent EventHandler Changed;.field private EventHandler Changed[CompilerGenerated] — same name as the eventadd_Changed(EventHandler value)specialname — CompareExchange loopremove_Changed(EventHandler value)specialname — CompareExchange loop.event EventHandler ChangedEvent row + MethodSemantics: AddOn, RemoveOn

El cuerpo del accesor desde C# 4: un bucle sin bloqueos

Aquí tienes el add_Changed completo tal como lo emite el compilador actual:

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

Léelo como el C# que representa:

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

El bucle existe porque los delegados son inmutables: Combine devuelve un nuevo delegado multicast, y si dos hilos se suscriben en el mismo instante, un ingenuo leer-combinar-escribir pierde a uno de ellos. CompareExchange publica el nuevo delegado solo si el campo todavía contiene la instantánea a partir de la cual se calculó; de lo contrario, devuelve el valor real del campo y el bucle vuelve a calcular a partir de ese. remove_Changed es byte a byte lo mismo con Delegate::Remove en lugar de Combine.

Fíjate en la instanciación genérica CompareExchange<EventHandler>(!!0&, !!0, !!0) — los tokens !!0 son el parámetro genérico del propio método. Un decompilador que haga coincidencia de patrones con este cuerpo debe hacerlo sobre la forma (ldfld, bucle, Combine/Remove, castclass, ldflda, CompareExchange genérico, bne.un de vuelta al almacenamiento de la instantánea) y no sobre tokens concretos, ya que el tipo del delegado, el campo y el orden de maxstack/variables locales del método varían.

Los eventos estáticos usan el mismo bucle con ldsfld/ldsflda y sin ldarg.0.

La forma antigua: [MethodImpl(Synchronized)]

Los ensamblados compilados con los compiladores de C# 1–3 — todavía abundantes en bases de código longevas y referencias de terceros — llevan un accesor distinto:

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

El flag synchronized en la cabecera del método (MethodImplOptions.Synchronized) hace que el runtime tome un bloqueo sobre this — o sobre el objeto del tipo, para eventos estáticos — alrededor de todo el cuerpo, así que el simple ldfld/Combine/stfld es seguro. Se reemplazó porque bloquear sobre this es un bloqueo público por el que cualquiera puede competir, y porque el bloqueo es mucho más caro que un CAS que casi nunca reintenta.

Ambos cuerpos significan exactamente lo mismo: este es el evento de tipo campo del compilador. Un decompilador que solo reconozca el bucle moderno mostrará el evento del ensamblado antiguo como un campo, dos métodos y un misterioso flag synchronized.

Invocar: las formas de ?.Invoke

El lado de la suscripción es solo la mitad de la historia. El autor también dispara el evento, y C# ha tenido tres idiomas para ello, cada uno con su propio IL. El moderno:

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:

El trío dup/brtrue/pop es la huella del operador condicional nulo: cargar una vez, comprobar la copia, usar el original. El idioma anterior a C# 6 lee el campo en una variable local y comprueba la local — un stloc/ldloc/brfalse en lugar de dup/brtrue/pop — y es semánticamente idéntico. La tercera forma es la que no tiene ninguna comprobación:

Changed(this, EventArgs.Empty);       // throws if nobody subscribed

que es un ldfld a secas + callvirt Invoke. Un decompilador debería renderizar cada una de ellas tal como se escribió, porque la diferencia es información: la forma a secas en una dependencia decompilada es una NullReferenceException esperando a una llamada sin suscriptores, y la forma con copia local en una base de código moderna te dice que el archivo es anterior a C# 6. Plegar las tres en ?.Invoke sería más limpio y estaría mal.

Cuando el autor escribió los accesores

La regla de plegado tiene una condición previa: los accesores tienen que ser del compilador. Las clases que exponen muchos eventos — los controles de WinForms son el caso clásico — evitan asignar un campo por evento guardando los suscriptores en un EventHandlerList indexado por un objeto estático:

private static readonly object ChangedKey = new();

public event EventHandler Changed
{
    add    => Events.AddHandler(ChangedKey, value);
    remove => Events.RemoveHandler(ChangedKey, value);
}

No hay campo Changed, no hay [CompilerGenerated] en los accesores, y los cuerpos son un ldsfld ChangedKey / ldarg.1 / callvirt EventHandlerList::AddHandler. La fila de Event y las filas de MethodSemantics tienen exactamente el mismo aspecto que en el caso de tipo campo — los metadatos sinceramente no pueden decirte quién escribió los accesores. Solo el cuerpo puede. Un decompilador que vea cualquier cosa distinta de las dos formas conocidas debe emitir add { … } / remove { … } tal cual, porque lo que haya ahí dentro — una búsqueda por clave, una llamada de reenvío, un registro por referencia débil — es un comportamiento que el autor eligió.

Dos variantes más completan la familia. Una implementación explícita de interfaz de un evento tiene accesores llamados IObservable.add_Changed con el .addon de la fila .event apuntando a ellos y una directiva .override, y debe renderizarse como event EventHandler IObservable.Changed { add … remove … } — las implementaciones explícitas no pueden ser de tipo campo. Un evento abstract o de interfaz tiene una fila de Event y declaraciones de accesores sin ningún cuerpo, y se renderiza como event EventHandler Changed; con el modificador abstract — el mismo texto que un evento de tipo campo, distinguido únicamente por la ausencia del campo y el flag abstract en los accesores.

Lo que emite Glass.NET

Junta las reglas de reconocimiento y la obligación para un evento de tipo campo queda clara. Quedarse en el nivel del metal muestra esto:

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

mientras que recuperar la intención del autor produce:

// Right: the line the author wrote
public event EventHandler Changed;

Glass.NET reconoce tanto el bucle sin bloqueos como la forma synchronized como de tipo campo, pliega el campo, los accesores y la fila de Event en una sola declaración, y conserva los cuerpos add/remove escritos a mano exactamente como se escribieron — incluido el patrón EventHandlerList, los eventos de interfaz explícitos y los eventos abstractos. En el sitio de invocación preserva cuál de los tres idiomas usó el código, de modo que un ?.Invoke, una comprobación con copia local o una invocación a secas se leen cada uno como lo que son. La palabra clave event siempre fue una ficción de código fuente sobre una fila de Event y dos métodos; el trabajo consiste en reconstruir la ficción sin perder el único caso en el que los métodos no eran ficción.

Consulta las tablas de metadatos explicadas para las tablas Event, EventMap y MethodSemantics que esto lee, y lambdas y clausuras para las instancias de delegado que normalmente acaban al otro lado de +=.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.