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

Decompilar volatile: el modreq del campo, el prefijo en cada acceso y la llamada a Volatile.Read que se parece pero no lo es

La palabra clave volatile no tiene un runtime propio. Compila en dos piezas de metadatos separadas que viven en lugares distintos: un modificador requerido, modreq(IsVolatile), soldado a la firma del campo, y un prefijo volatile. de un byte estampado delante de cada ldfld y stfld que toca el campo. Un decompilador tiene que leer los dos, ponerse de acuerdo consigo mismo sobre cuál manda, y luego no confundir el resultado con las dos cosas que parecen idénticas en C# pero compilan de forma completamente distinta: Volatile.Read sobre un campo corriente, que es una llamada normal sin prefijo y sin modificador, e Interlocked.Increment sobre un campo volatile, que toma el campo por dirección y, por tanto, lleva el modificador pero no el prefijo. Esta entrada recorre el IL de los tres, muestra qué reconstruye Glass.NET a partir de cada uno, explica por qué el propio token de referencia al campo tiene que llevar el modreq o el acceso ni siquiera resuelve, y cubre el único caso que C# no sabe deletrear —un prefijo volatile. sin un campo volatile detrás— y la llamada que Glass emite para él.

volatile es una de las palabras clave más pequeñas de C# y una de las más repartidas en IL. No compila a una instrucción. Compila a un modificador en la firma del campo y a un prefijo en cada acceso al campo, en dos lugares distintos de los metadatos, y un decompilador tiene que leer los dos y decidir en cuál cree. Luego tiene que evitar confundir la palabra clave con sus dos parecidos: Volatile.Read sobre un campo que no es volatile, e Interlocked.Increment sobre un campo que sí lo es. Los tres se leen como un acceso seguro para hilos en C#; los tres compilan a un IL distinto. Esta entrada recorre los tres para Glass.NET.

Una palabra clave, dos piezas de metadatos

Empieza por la forma corriente, una bandera de parada que sondea un bucle de trabajo:

public sealed class Worker
{
    private volatile bool _stop;
    private int _iterations;

    public void RequestStop() => _stop = true;

    public void Run()
    {
        while (!_stop) { DoWork(); _iterations++; }
    }
}

Fíjate primero en el campo. La declaración no es bool; es bool llevando puesto un modificador requerido:

.field private bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) _stop
.field private int32 _iterations

modreq —modificador requerido, en contraposición a modopt— significa que el modificador forma parte de la firma. Un consumidor que no lo entienda debe negarse a enlazar, que es por lo que IsVolatile es un modreq: un compilador que lo ignorase emitiría en silencio un acceso no volátil a un campo volatile. El efecto práctico para un decompilador es que toda referencia al campo lleva también el modificador, porque un token de referencia a un campo se resuelve por nombre y firma, y bool _stop es una firma distinta de bool modreq(IsVolatile) _stop.

Ahora los accesos. RequestStop es un almacenamiento, Run es una carga en un bucle:

.method public hidebysig instance void RequestStop() cil managed
{
  ldarg.0
  ldc.i4.1
  volatile.
  stfld      bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) Worker::_stop
  ret
}

.method public hidebysig instance void Run() cil managed
{
  IL_0000:  br.s       IL_0016
  IL_0002:  ldarg.0
  IL_0003:  call       instance void Worker::DoWork()
  IL_0008:  ldarg.0
  IL_0009:  dup
  IL_000a:  ldfld      int32 Worker::_iterations
  IL_000f:  ldc.i4.1
  IL_0010:  add
  IL_0011:  stfld      int32 Worker::_iterations
  IL_0016:  ldarg.0
  IL_0017:  volatile.
  IL_0019:  ldfld      bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) Worker::_stop
  IL_001e:  brtrue.s   IL_0002
  IL_0020:  ret
}

Dos cosas que notar. El prefijo volatile. (0xFE 0x13, que es por lo que el siguiente desplazamiento salta de dos en dos) se sitúa justo antes del ldfld/stfld y se aplica solo a esa única instrucción: es la orden del JIT de no sacar la carga fuera del bucle ni mantener el valor en un registro. Y los accesos a _iterations que están justo al lado no tienen prefijo, porque ese campo no tiene modificador. Por acceso, por campo: ese es el par.

Tres accesos que se parecen en C#, y su IL1. campo volatile.field bool modreq(IsVolatile) _stopvolatile. ldfld _stopmodificador: sí prefijo: sí→ volatile bool _stop; lectura normal2. Volatile.Read(ref _flag).field bool _flagldflda _flag; call Volatile::Readmodificador: no prefijo: no→ se mantiene la llamada Volatile.Read3. Interlocked sobre volatileldflda int32 modreq(IsVolatile)_count; call Interlocked::Incrementmodificador: sí prefijo: no→ Interlocked.Increment(ref _count)Cómo decide Glass.NETEl modificador del campo es la fuente de verdad: C# tiene una sola forma de deletrear la volatilidad, la palabra clave en la declaración,e implica el prefijo en cada carga y almacenamiento directos; así, un acceso con prefijo a un campo modreq se representa como un acceso normal.Un acceso que toma dirección (ldflda) lleva el modificador pero nunca puede llevar el prefijo; la call que sigue se representa tal como se escribió.Un prefijo sin un campo modreq detrás no es expresable en C#; Glass lo representa como la llamada explícita Volatile.Read / Volatile.Write.

Qué lee Glass, y en qué mitad cree

C# tiene exactamente una manera de decir «volatile»: el modificador en la declaración. No hay sintaxis para una lectura no volátil de un campo volatile, ni sintaxis para una lectura volátil de un campo corriente usando el prefijo: ese caso tiene que pasar por Volatile.Read, que es una llamada a método. Así que el modificador a nivel de campo es lo único que el lenguaje sabe expresar, y es lo que Glass.NET toma como la fuente de verdad.

La reconstrucción es, por tanto: leer modreq(IsVolatile) de la firma del campo → emitir volatile en la declaración → esperar un prefijo volatile. en cada ldfld/stfld/ldsfld/stsfld directo de ese campo → emitir un acceso normal, porque la palabra clave de la declaración ya carga el significado. El resultado es el código fuente del que partiste:

private volatile bool _stop;     // from the modreq on the field
private int _iterations;

public void RequestStop() { _stop = true; }          // prefix consumed by the declaration

public void Run()
{
    while (!_stop) { DoWork(); _iterations++; }      // same
}

El prefijo no se tira; se comprueba. Si un campo tiene el modificador pero a un acceso directo le falta el prefijo, ese IL no vino del compilador de C# —vino de IL escrito a mano, de un reescritor o de otro lenguaje— y Glass representa el acceso tal como se escribió y lo anota, porque promoverlo en silencio a la palabra clave afirmaría una barrera que no está ahí. La otra dirección, un prefijo sin modificador, es el caso del final de esta entrada.

Parecido uno: Volatile.Read sobre un campo que no es volatile

Ahora la forma a la que recurres en código caliente donde solo una lectura necesita sincronizarse y, por lo demás, el campo se accede con libertad:

private bool _ready;                                  // not volatile

public void Publish() { /* ... */ Volatile.Write(ref _ready, true); }
public bool IsReady  => Volatile.Read(ref _ready);

Esto se lee como un campo volatile con una palabra de más. El IL no tiene nada en común con el primer caso:

.field private bool _ready                          // no modreq

.method public hidebysig specialname instance bool get_IsReady() cil managed
{
  ldarg.0
  ldflda     bool Worker::_ready                    // take the ADDRESS — no prefix possible
  call       bool [System.Runtime]System.Threading.Volatile::Read(bool&)
  ret
}

Sin modificador en el campo, sin prefijo en ninguna instrucción. La volatilidad vive en el destinatario, y se aplica solo a este acceso: cualquier otra lectura de _ready en el tipo es una carga normal sin barrera, que es precisamente la gracia de escribirlo así. Glass.NET mantiene la llamada exactamente como se escribió: un campo no volátil y un Volatile.Read explícito. Reducirlo a private volatile bool _ready; con una lectura normal sería un cambio semántico: pondría una barrera en todos los demás accesos al campo, incluidos los que el autor dejó normales a propósito. Las dos formas tienen la misma garantía en este sitio y garantías distintas en todas partes, y un decompilador fiel preserva la diferencia.

Parecido dos: Interlocked.Increment sobre un campo que sí es volatile

La tercera forma es la que produce una advertencia del compilador, y el IL explica la advertencia mejor que la documentación:

private volatile int _count;

public void Bump() => Interlocked.Increment(ref _count);   // warning CS0420

Interlocked.Increment toma ref int. Para pasar un campo por referencia, el compilador debe tomar su dirección, y una dirección no es una carga ni un almacenamiento: no hay nada a lo que poner un prefijo volatile.:

.field private int32 modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) _count

.method public hidebysig instance void Bump() cil managed
{
  ldarg.0
  ldflda     int32 modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) Worker::_count
  call       int32 [System.Runtime]System.Threading.Interlocked::Increment(int32&)
  pop
  ret
}

El token ldflda sí lleva el modificador —debe hacerlo, o la referencia al campo no resolvería— pero no hay prefijo, porque el acceso a través de la referencia ocurre dentro de Increment, que de todas formas realiza una lectura-modificación-escritura atómica que es al menos tan fuerte como un acceso volátil. Por eso CS0420 (“a reference to a volatile field will not be treated as volatile”) es una advertencia y no un error: el código es correcto aquí, lo que pasa es que el compilador no puede demostrarlo en general.

Glass.NET lo lee de vuelta como Interlocked.Increment(ref _count) con _count todavía declarado volatile, porque el modificador sigue en el campo. La advertencia desaparece, y no es recuperable: una advertencia es el comentario del compilador, no metadatos, y nada en el ensamblado registra que alguna vez se emitiera. El código decompilado es el código.

El caso que C# no sabe deletrear

Todo lo anterior vino del compilador de C#, que solo emite el prefijo en accesos a campos modreq. Otros productores no son tan pulcros. El IL escrito a mano, los reescritores de IL y otros compiladores pueden poner volatile. delante de un ldfld de un campo sin modificador, o delante de ldind.i4/stind.i4/ldobj/stobj sobre un puntero o una referencia gestionada, y cpblk/initblk también lo aceptan:

ldarg.1                 // a ref int parameter
volatile.
ldind.i4                // volatile read through a reference — no field, no modreq

C# no tiene palabra clave para esto. La única construcción del lenguaje que significa «un acceso volátil a una ubicación que no es un campo volatile» es la API Volatile, así que eso es lo que Glass emite:

Volatile.Read(ref value)      // what Glass renders for volatile. ldind.i4 on a ref parameter

No es lo que escribió el autor original —él escribió IL— pero es el único C# que significa lo que escribió, y compila a una llamada con la misma semántica de adquisición. La alternativa, descartar el prefijo y representar una desreferencia normal, eliminaría una barrera en silencio. Cuando la forma es inusual, Glass prefiere la construcción que preserva la garantía antes que la que se parece al código fuente típico.

La conclusión

volatile es un modificador a nivel de campo y un prefijo por acceso que viven en dos partes distintas de los metadatos, y el modificador es la mitad que un decompilador sabe expresar: Glass.NET lee modreq(IsVolatile) del campo, emite la palabra clave en la declaración y trata el prefijo de cada carga y almacenamiento directos como confirmación más que como información. Los dos parecidos conservan sus propias formas por una buena razón: Volatile.Read sobre un campo corriente es una dirección y una llamada sin modificador, y promoverlo a la palabra clave pondría barreras en accesos que el autor dejó normales; Interlocked.Increment sobre un campo volatile es una dirección con el modificador y sin prefijo, y se representa tal como se escribió mientras que su advertencia CS0420 simplemente no es recuperable. Y la única forma que C# no sabe deletrear —un prefijo sin un campo volatile detrás— sale como la llamada a Volatile, porque preservar la barrera importa más que parecerse al código fuente típico.

Prueba Nebula.NET

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