Decompiling fixed: pinned locals, the null-or-empty array guard, and GetPinnableReference
There is no fixed statement in IL. What the C# compiler emits instead is a local variable with the pinned modifier in its signature, a few instructions that compute a pointer from whatever you pinned — with guards you never wrote — and a store that clears the local at the end of the block. There is no try/finally, because a pin is a property of a local's lifetime rather than a region of code. The initializer is lowered four different ways depending on what you pinned: an array gets a null-or-empty check and ldelema of element zero, a string gets either the legacy OffsetToStringData arithmetic or a call to GetPinnableReference, a Span or any type with a GetPinnableReference method gets that call on its address, and &field or &local gets a plain ldflda or ldloca. This post reads the real IL for each shape, explains why an empty array yields a null pointer rather than an exception, and shows how Glass.NET anchors on the pinned local to fold the machinery back into the one-line fixed you wrote.
fixed is the smallest piece of syntax in C#‘s unsafe toolkit and one of the more interesting things to find in IL, because almost nothing of it survives. There is no fixed opcode. There is no pinned instruction. There is no try/finally — unlike using and foreach, which generate one for their cleanup, a fixed block has no cleanup call to make. What there is instead is a local variable with an unusual type modifier, a few instructions that compute a pointer, and a store that clears the local when you are done.
And the initializer — the thing after the = — is lowered four different ways depending on what you pinned. A decompiler has to recognize each one to print the single line you wrote. This post reads the IL for all four and shows how Glass.NET gets back to fixed.
The anchor: a local with the pinned modifier
Everything hangs on one piece of metadata. Take the simplest case:
unsafe int First(byte[] data)
{
fixed (byte* p = data)
{
return *p;
}
}
The method’s local variable signature — the LocalVarSig blob the method header points at — declares two locals:
.locals init (
[0] uint8* p,
[1] uint8& pinned
)
Local 1 is a managed reference to a byte (uint8&) carrying the pinned constraint. pinned is an element-type modifier in the signature encoding (ELEMENT_TYPE_PINNED, 0x45), and it means exactly one thing to the runtime: while this local holds a non-null reference, the garbage collector may not move the object it points into. That is the whole mechanism. There is no instruction that pins; the local pins, for as long as it is live and non-null.
This is also why fixed is recoverable without a PDB. The pinned modifier is part of the method’s signature metadata, which the runtime needs and which therefore ships in every assembly, debug symbols or not. A decompiler that finds a pinned local knows a fixed was here; the rest is working out what was pinned and for how long.
Shape 1: an array, and the guard you never wrote
Here is the body of First, and it is longer than the source would suggest:
IL_0000: ldarg.1
IL_0001: dup
IL_0002: stloc.2 // hidden temp = data
IL_0003: brfalse.s IL_000a // data == null → null pointer
IL_0005: ldloc.2
IL_0006: ldlen
IL_0007: conv.i4
IL_0008: brtrue.s IL_000f // data.Length != 0 → pin it
IL_000a: ldc.i4.0
IL_000b: conv.u
IL_000c: stloc.0 // p = null
IL_000d: br.s IL_0019
IL_000f: ldloc.2
IL_0010: ldc.i4.0
IL_0011: ldelema [System.Runtime]System.Byte
IL_0016: stloc.1 // pinned = ref data[0] ← the pin begins
IL_0017: ldloc.1
IL_0018: conv.u
IL_0019: stloc.0 // p = (byte*)pinned
IL_001a: ldloc.0
IL_001b: ldind.u1 // *p
IL_001c: stloc.3
IL_001d: ldc.i4.0
IL_001e: conv.u
IL_001f: stloc.1 // pinned = null ← the pin ends
IL_0020: ldloc.3
IL_0021: ret
Two things in there are not in your source.
The first is the null-or-empty guard. You wrote fixed (byte* p = data) and the compiler emitted a null check and a length check before pinning anything. The reason is the language rule for arrays: taking &data[0] of an empty array would throw IndexOutOfRangeException, and the spec instead defines fixed over a null or zero-length array to produce a null pointer. So the lowered code is three-way — null array, empty array, real array — and only the third path actually executes ldelema and stores into the pinned local. The other two store a null pointer into p and never pin at all. This is worth knowing as a reader of unsafe code: *p on the empty-array path dereferences null, and the fault happens at the use, not at the fixed.
The second is the clearing store at IL_001d–IL_001f. The compiler stores a null reference into the pinned local at the normal end of the block. This is not a correctness requirement — the pin would end anyway when the local went dead or the frame unwound — it is the compiler ending the pin promptly, so the GC can compact the array as soon as you are done rather than at method exit. In optimized builds where the block is immediately followed by ret, the compiler sometimes omits it, which is why a decompiler must treat the clearing store as optional: its absence means “the method ended,” not “this was not a fixed.”
Shape 2: a string, in two generations
Strings have been pinnable since C# 1, and the lowering changed along the way, so a decompiler meets two shapes for the same source line:
unsafe int FirstChar(string s)
{
fixed (char* p = s)
return p == null ? -1 : *p;
}
The legacy shape — what you get targeting .NET Framework, and what older Roslyn versions emitted everywhere — pins the string object itself and does pointer arithmetic to reach its characters:
.locals init (
[0] char* p,
[1] string pinned
)
IL_0000: ldarg.1
IL_0001: stloc.1 // pinned = s (the whole object is pinned)
IL_0002: ldloc.1
IL_0003: conv.u
IL_0004: stloc.0 // p = (char*)object address
IL_0005: ldloc.0
IL_0006: brfalse.s IL_0010 // s == null → leave p null
IL_0008: ldloc.0
IL_0009: call int32 [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::get_OffsetToStringData()
IL_000e: add
IL_000f: stloc.0 // p += offset of the first char inside the string object
...
IL_00xx: ldnull
IL_00xx: stloc.1 // pinned = null
The pinned local here is of type string — a pinned object reference rather than a pinned managed pointer — and RuntimeHelpers.OffsetToStringData is the runtime’s answer to “how many bytes from the start of a String object to its first char.” The compiler adds that to the object address to land on the characters. The null guard is there because OffsetToStringData added to a null address would produce a bogus non-null pointer; fixed over a null string must yield null.
The modern shape — .NET Core 3.0 and later, where string exposes GetPinnableReference() — drops the arithmetic entirely:
.locals init (
[0] char* p,
[1] char& pinned
)
IL_0000: ldarg.1
IL_0001: dup
IL_0002: stloc.2
IL_0003: brtrue.s IL_000b // s != null → pin
IL_0005: ldc.i4.0
IL_0006: conv.u
IL_0007: stloc.0 // p = null
IL_0008: br.s IL_0015
IL_000b: ldloc.2
IL_000c: callvirt instance char& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute)
[System.Runtime]System.String::GetPinnableReference()
IL_0011: stloc.1 // pinned = ref s[0] (a ref into the string's chars)
IL_0012: ldloc.1
IL_0013: conv.u
IL_0014: stloc.0 // p = (char*)pinned
IL_0015: ...
IL_00xx: ldc.i4.0
IL_00xx: conv.u
IL_00xx: stloc.1 // pinned = null
Now the pinned local is a char& — a managed pointer directly at the first character — and the method that produced it is string.GetPinnableReference(), whose return type is ref readonly char (the modreq(InAttribute) on the IL signature is how ref readonly is spelled in metadata). No offset, no arithmetic. The same source line, two completely different instruction sequences, both of which a decompiler must map back to fixed (char* p = s). (The exact branch layout varies a little between Roslyn versions; the fingerprints — the pinned local, the null guard, the GetPinnableReference call or the OffsetToStringData add — do not.)
Shape 3: spans and the GetPinnableReference pattern
C# 7.3 generalized the modern string shape into a pattern: any type with an accessible GetPinnableReference() method returning ref T or ref readonly T can be the operand of fixed, and Span<T>, ReadOnlySpan<T> and Memory<T>-adjacent types all have one.
unsafe int Sum(ReadOnlySpan<int> values)
{
int total = 0;
fixed (int* p = values)
for (int i = 0; i < values.Length; i++) total += p[i];
return total;
}
.locals init (
[0] int32 total,
[1] int32* p,
[2] int32& pinned,
[3] int32 i
)
IL_0002: ldarga.s values
IL_0004: call instance !0& modreq(InAttribute) valuetype [System.Runtime]System.ReadOnlySpan`1<int32>::GetPinnableReference()
IL_0009: stloc.2 // pinned = ref values[0] (or a null ref for an empty span)
IL_000a: ldloc.2
IL_000b: conv.u
IL_000c: stloc.1 // p = (int*)pinned
...
IL_00xx: ldc.i4.0
IL_00xx: conv.u
IL_00xx: stloc.2 // pinned = null
Two differences from the string version stand out. The receiver is a value type, so the call is on ldarga.s values — the address of the span — with a plain call, and there is no null guard: a struct cannot be null, and Span<T>.GetPinnableReference() is specified to return a null reference for an empty span itself (so p comes out null on empty input, matching the array rule, but by the callee’s contract rather than by the caller’s guard). The decompiler’s job is therefore slightly different per shape: for an array it has to recognize and absorb the guard; for a span it has to recognize that the absence of a guard is normal.
The pattern also applies to your own types. Give a struct a public ref byte GetPinnableReference() — or supply one as an extension method — and fixed (byte* p = myBuffer) compiles to the same call/stloc pinned/conv.u triple. A decompiler reading a call to a method named GetPinnableReference whose result is stored into a pinned local can fold it regardless of the declaring type, which is exactly the duck-typed rule the compiler used.
Shape 4: an explicit address
The last form is the one with the least lowering, because you supplied the address yourself:
struct Header { public int Magic; public int Length; }
unsafe int ReadLength(Header[] headers)
{
fixed (int* p = &headers[3].Length)
return *p;
}
.locals init ([0] int32* p, [1] int32& pinned)
IL_0000: ldarg.1
IL_0001: ldc.i4.3
IL_0002: ldelema Header
IL_0007: ldflda int32 Header::Length
IL_000c: stloc.1 // pinned = ref headers[3].Length
IL_000d: ldloc.1
IL_000e: conv.u
IL_000f: stloc.0
ldelema and ldflda produce the managed reference directly; it goes into the pinned local, then conv.u into the pointer. No guard — the indexer throws on its own if 3 is out of range, and a field access on a null object throws on its own — so the compiler adds nothing. A decompiler recognizes this shape by elimination: a pinned local initialized from an address-producing instruction that is neither element zero behind a guard nor a GetPinnableReference call is an explicit &expr, and the expression is whatever the address computation decompiles to.
Fixed-size buffers — fixed int Values[8]; inside a struct — are a different feature that happens to share the keyword; they produce a nested buffer type and ldflda against it, and on modern targets are being displaced by inline arrays. Decompiling inline arrays and stackalloc covers that side.
How Glass.NET folds it back
Putting the four shapes together, fixed recovery in Glass.NET is anchored on the one thing all of them share and then dispatched on the initializer.
The steps, in order:
- Find the pinned locals. They are declared in the local signature, so this is a metadata read, not an inference. Each pinned local is a candidate
fixed. - Delimit the region. It begins at the first store of a non-null value into the pinned local and ends at the store that clears it —
ldc.i4.0; conv.u; stlocfor a pinned ref,ldnull; stlocfor a pinned object — or, when no clearing store exists, at the method’s exit. Roslyn reuses pinned slots for sequentialfixedstatements in the same method, so one pinned local may delimit several regions; each clearing store closes one and the next non-null store opens another. - Classify the initializer by the instruction that produced the value stored into the pinned local:
ldelemawith constant index0behind a null/length guard is an array; aGetPinnableReferencecall (any declaring type) is the modern string/span/custom shape, with the guard absorbed when the receiver is a reference type;OffsetToStringDataarithmetic on a pinnedstringis the legacy string shape; anything else that yields a managed reference is&expr, decompiled as the expression it is. - Identify the fixed variable. It is the unmanaged pointer local that the
conv.uof the pinned local is stored into. Its declared pointer type (uint8*,char*,int32*) becomes the type infixed (T* p = …), and uses of it in the region are uses ofp. - Fold — delete the guard, the temp, the pinned local, the
conv.u, and the clearing store; emitfixed (T* p = operand) { region }.
When a shape does not line up — the pinned local is read for its own value inside the region, the guard tests something other than the pinned operand, a pointer local is assigned from two different pinned locals — Glass.NET declines to fold and shows the lowered form instead, with the pinned local annotated as // pinned. There is no C# syntax for a pinned local, so the annotation is the honest rendering; a fixed that misrepresents what is pinned, or for how long, would be worse than an unfolded one.
Why this matters when you are reading someone else’s code
fixed shows up precisely in the code you most want to read carefully: P/Invoke marshalling, hand-rolled parsers over Span<byte>, image and audio codecs, anything that hands a pointer to native code. Three details of the lowering are what you are actually checking when you review it. The empty-array null pointer is a classic bug class — the pointer is null and the first dereference faults, far from the fixed. The pin lifetime is the second: the pin ends when the local is cleared or the frame unwinds, so a pointer that escapes the block — stored in a field, handed to a native callback that fires later — points into memory the GC is free to move the moment the method returns. And the absence of a guard on the span shape means the callee’s contract, not your code, is what keeps the empty case safe.
A decompiler that prints fixed (byte* p = data) has shown you the intent. A decompiler that can also show you the lowered form — the guard, the pinned local, the clearing store — has shown you what the runtime will actually do with it, which for unsafe code is the thing that matters.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.