Decompiling volatile: the modreq on the field, the prefix on every access, and the Volatile.Read call that looks the same but is not
The volatile keyword has no runtime of its own. It compiles into two separate pieces of metadata that live in different places: a required modifier, modreq(IsVolatile), welded into the field's signature, and a one-byte volatile. prefix stamped in front of every ldfld and stfld that touches the field. A decompiler has to read both, agree with itself about which one is authoritative, and then not confuse the result with the two things that look identical in C# but compile completely differently — Volatile.Read on an ordinary field, which is a plain call with no prefix and no modifier, and Interlocked.Increment on a volatile field, which takes the field by address and therefore carries the modifier but not the prefix. This post walks the IL for all three, shows what Glass.NET reconstructs from each, explains why the field reference token itself has to carry the modreq or the access does not even resolve, and covers the one case C# cannot spell — a volatile. prefix with no volatile field behind it — and the call Glass emits for it.
volatile is one of the smallest keywords in C# and one of the most spread-out in IL. It does not compile to an instruction. It compiles to a modifier on the field’s signature and a prefix on every access to the field, in two different places in the metadata, and a decompiler has to read both and decide which one it believes. Then it has to avoid mistaking the keyword for its two look-alikes: Volatile.Read on a field that is not volatile, and Interlocked.Increment on a field that is. All three read like a thread-safe access in C#; all three compile to different IL. This post walks the three for Glass.NET.
One keyword, two pieces of metadata
Start with the ordinary shape, a stop flag polled by a worker loop:
public sealed class Worker
{
private volatile bool _stop;
private int _iterations;
public void RequestStop() => _stop = true;
public void Run()
{
while (!_stop) { DoWork(); _iterations++; }
}
}
Look at the field first. The declaration is not bool; it is bool wearing a required modifier:
.field private bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) _stop
.field private int32 _iterations
modreq — required modifier, as opposed to modopt — means the modifier is part of the signature. A consumer that does not understand it must refuse to bind, which is why IsVolatile is a modreq: a compiler that ignored it would silently emit non-volatile access to a volatile field. The practical effect for a decompiler is that every reference to the field carries the modifier too, because a field reference token is resolved by name and signature, and bool _stop is a different signature from bool modreq(IsVolatile) _stop.
Now the accesses. RequestStop is a store, Run is a load in a loop:
.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
}
Two things to notice. The volatile. prefix (0xFE 0x13, which is why the next offset jumps by two) sits immediately before the ldfld/stfld and applies only to that one instruction — it is the JIT’s instruction not to hoist the load out of the loop or keep the value in a register. And the _iterations accesses right next to it have no prefix, because that field has no modifier. Per access, per field: that is the pair.
What Glass reads, and which half it believes
C# has exactly one way to say “volatile”: the modifier on the declaration. There is no syntax for a non-volatile read of a volatile field, and no syntax for a volatile read of an ordinary field using the prefix — that case has to go through Volatile.Read, which is a method call. So the field-level modifier is the only thing the language can express, and it is the thing Glass.NET takes as the source of truth.
The reconstruction is therefore: read modreq(IsVolatile) off the field signature → emit volatile on the declaration → expect a volatile. prefix on every direct ldfld/stfld/ldsfld/stsfld of that field → emit a plain access, because the keyword on the declaration already carries the meaning. The result is the source you started with:
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
}
The prefix is not thrown away; it is checked. If a field has the modifier but a direct access lacks the prefix, that IL did not come from the C# compiler — it came from hand-written IL, a rewriter, or another language — and Glass renders the access as written and annotates it, because silently promoting it to the keyword would claim a fence that is not there. The other direction, a prefix with no modifier, is the case at the end of this post.
Look-alike one: Volatile.Read on a field that is not volatile
Now the shape you reach for in hot code where only one read needs to synchronise and the field is otherwise accessed freely:
private bool _ready; // not volatile
public void Publish() { /* ... */ Volatile.Write(ref _ready, true); }
public bool IsReady => Volatile.Read(ref _ready);
This reads like a volatile field with one extra word. The IL has nothing in common with the first case:
.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
}
No modifier on the field, no prefix on any instruction. The volatility lives in the callee, and it applies to this one access only — every other read of _ready in the type is a plain unfenced load, which is precisely the point of writing it this way. Glass.NET keeps the call exactly as written: a non-volatile field and an explicit Volatile.Read. Collapsing it to private volatile bool _ready; with a plain read would be a semantic change — it would fence every other access to the field, including the ones the author deliberately left plain. The two shapes have the same guarantee at this site and different guarantees everywhere else, and a faithful decompiler preserves the difference.
Look-alike two: Interlocked.Increment on a field that is volatile
The third shape is the one that produces a compiler warning, and the IL explains the warning better than the documentation does:
private volatile int _count;
public void Bump() => Interlocked.Increment(ref _count); // warning CS0420
Interlocked.Increment takes ref int. To pass a field by reference the compiler must take its address, and an address is not a load or a store — there is nothing to put a volatile. prefix on:
.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
}
The ldflda token does carry the modifier — it must, or the field reference would not resolve — but there is no prefix, because the access through the reference happens inside Increment, which performs an atomic read-modify-write that is at least as strong as a volatile access anyway. That is why CS0420 (“a reference to a volatile field will not be treated as volatile”) is a warning and not an error: the code is correct here, the compiler just cannot prove it in general.
Glass.NET reads this back as Interlocked.Increment(ref _count) with _count still declared volatile, because the modifier is still on the field. The warning is gone, and it is not recoverable: a warning is the compiler’s commentary, not metadata, and nothing in the assembly records that it was ever raised. Decompiled code is the code.
The case C# cannot spell
Everything above came from the C# compiler, which only ever emits the prefix on accesses to modreq fields. Other producers are not so tidy. Hand-written IL, IL rewriters, and other compilers can put volatile. in front of an ldfld of a field with no modifier, or in front of ldind.i4/stind.i4/ldobj/stobj on a pointer or managed reference, and cpblk/initblk accept it too:
ldarg.1 // a ref int parameter
volatile.
ldind.i4 // volatile read through a reference — no field, no modreq
C# has no keyword for this. The only construct in the language that means “a volatile access to a location that is not a volatile field” is the Volatile API, so that is what Glass emits:
Volatile.Read(ref value) // what Glass renders for volatile. ldind.i4 on a ref parameter
It is not what the original author wrote — they wrote IL — but it is the only C# that means what they wrote, and it compiles to a call with the same acquire semantics. The alternative, dropping the prefix and rendering a plain dereference, would silently remove a fence. When the shape is unusual, Glass prefers the construct that preserves the guarantee over the one that looks like typical source.
The takeaway
volatile is a field-level modifier and a per-access prefix living in two different parts of the metadata, and the modifier is the half a decompiler can express — Glass.NET reads modreq(IsVolatile) off the field, emits the keyword on the declaration, and treats the prefix on each direct load and store as confirmation rather than information. The two look-alikes keep their own shapes for good reason: Volatile.Read on an ordinary field is an address and a call with no modifier, and promoting it to the keyword would fence accesses the author left plain; Interlocked.Increment on a volatile field is an address with the modifier and no prefix, and renders as written while its CS0420 warning is simply not recoverable. And the one shape C# cannot spell — a prefix with no volatile field behind it — comes out as the Volatile call, because preserving the fence matters more than looking like typical source.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.