# Decompiling fixed: pinned locals, the null-or-empty array guard, and GetPinnableReference


`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](https://delta1labs.com/products/glass) gets back to `fixed`.

## The anchor: a local with the `pinned` modifier

Everything hangs on one piece of metadata. Take the simplest case:

```csharp
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."

<figure class="my-8"><svg viewBox="0 0 660 238" role="img" aria-label="Diagram showing the lifetime of a pinned local compared with a try/finally. The fixed region begins at the store of a managed reference into the pinned local and ends at the store of null into it, or at method exit. Unlike using and foreach there is no handler, because unpinning is not a call: the garbage collector stops treating the object as pinned once the local no longer references it. On an exceptional exit the pin ends when the frame unwinds." style="width:100%;height:auto"><defs><style>.ti{font:600 12px system-ui;fill:#1547e0}.h{font:600 11px system-ui;fill:#0f172a}.x{font:11px system-ui;fill:#475569}.mono{font:11px ui-monospace,Consolas,monospace;fill:#1e293b}.pin{fill:#fef3c7;stroke:#fbbf24}.pre{fill:#dcfce7;stroke:#4ade80}.off{fill:#eef4ff;stroke:#8eb4ff}.no{font:600 11px system-ui;fill:#b91c1c}</style></defs><text class="ti" x="12" y="18">A pin is a lifetime, not a region</text><rect class="pre" x="12" y="30" width="636" height="48" rx="5"/><text class="mono" x="24" y="48">if (data == null || data.Length == 0) p = null;   // guard the compiler added</text><text class="mono" x="24" y="66">else { pinned = ref data[0]; p = (byte*)pinned; } // the pin BEGINS at this store</text><rect class="pin" x="12" y="86" width="636" height="70" rx="5"/><text class="h" x="24" y="104">while `pinned` holds a reference, the GC may not move `data`</text><text class="mono" x="24" y="124">... body: *p, p[i], pass p to native code ...</text><text class="x" x="24" y="144">no .try, no handler — nothing has to RUN when the block ends</text><rect class="off" x="12" y="164" width="636" height="48" rx="5"/><text class="mono" x="24" y="182">pinned = null;                                     // the pin ENDS (optional, prompt)</text><text class="x" x="24" y="200">on an exception the pin ends when the frame unwinds; on `ret` the local dies</text><text class="no" x="24" y="230">A decompiler looking for a finally here will not find one — the anchor is the pinned local's lifetime.</text></svg></figure>

## 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:

```csharp
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.

```csharp
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:

```csharp
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](https://delta1labs.com/blog/decompile-inline-arrays-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.

<figure class="my-8"><svg viewBox="0 0 660 214" role="img" aria-label="Table mapping each fixed initializer shape to its IL fingerprint and the decompiled output. Array: null-or-empty guard then ldelema element zero into a pinned T-ref, printed as fixed (T* p = array). String, legacy: string pinned local with OffsetToStringData add, printed as fixed (char* p = s). String or span or custom type: a GetPinnableReference call stored into a pinned ref, printed as fixed (T* p = operand). Explicit address: ldflda, ldloca or ldelema with a non-zero index stored into a pinned ref, printed as fixed (T* p = &expr)." style="width:100%;height:auto"><defs><style>.hd{font:600 12px system-ui;fill:#fff}.rc{font:11px system-ui;fill:#1e293b}.mono{font:10.5px ui-monospace,Consolas,monospace;fill:#1e293b}.rh{font:600 11px system-ui;fill:#0f172a}.cell{fill:#f8fafc;stroke:#cbd5e1}.hcell{fill:#1547e0}</style></defs><rect class="hcell" x="12" y="12" width="118" height="28"/><rect class="hcell" x="132" y="12" width="300" height="28"/><rect class="hcell" x="434" y="12" width="214" height="28"/><text class="hd" x="22" y="31">Operand</text><text class="hd" x="142" y="31">IL fingerprint (into a pinned local)</text><text class="hd" x="444" y="31">Glass.NET prints</text><text class="rh" x="12" y="64">array</text><rect class="cell" x="132" y="46" width="300" height="34" rx="4"/><text class="mono" x="140" y="60">brfalse / ldlen brtrue guard,</text><text class="mono" x="140" y="74">ldc.i4.0 ldelema T → stloc T&amp; pinned</text><rect class="cell" x="434" y="46" width="214" height="34" rx="4"/><text class="mono" x="442" y="67">fixed (T* p = array)</text><text class="rh" x="12" y="106">string (legacy)</text><rect class="cell" x="132" y="88" width="300" height="34" rx="4"/><text class="mono" x="140" y="102">stloc string pinned, conv.u,</text><text class="mono" x="140" y="116">brfalse guard, OffsetToStringData add</text><rect class="cell" x="434" y="88" width="214" height="34" rx="4"/><text class="mono" x="442" y="109">fixed (char* p = s)</text><text class="rh" x="12" y="148">string / span / custom</text><rect class="cell" x="132" y="130" width="300" height="34" rx="4"/><text class="mono" x="140" y="144">[null guard if ref type]</text><text class="mono" x="140" y="158">call GetPinnableReference → stloc T&amp; pinned</text><rect class="cell" x="434" y="130" width="214" height="34" rx="4"/><text class="mono" x="442" y="151">fixed (T* p = operand)</text><text class="rh" x="12" y="190">&amp;expr</text><rect class="cell" x="132" y="172" width="300" height="34" rx="4"/><text class="mono" x="140" y="186">ldflda / ldloca / ldelema (index ≠ 0 or</text><text class="mono" x="140" y="200">unguarded) → stloc T&amp; pinned</text><rect class="cell" x="434" y="172" width="214" height="34" rx="4"/><text class="mono" x="442" y="193">fixed (T* p = &amp;expr)</text></svg></figure>

The steps, in order:

1. **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`.
2. **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; stloc` for a pinned ref, `ldnull; stloc` for a pinned object — or, when no clearing store exists, at the method's exit. Roslyn reuses pinned slots for sequential `fixed` statements 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.
3. **Classify the initializer** by the instruction that produced the value stored into the pinned local: `ldelema` with constant index `0` behind a null/length guard is an array; a `GetPinnableReference` call (any declaring type) is the modern string/span/custom shape, with the guard absorbed when the receiver is a reference type; `OffsetToStringData` arithmetic on a pinned `string` is the legacy string shape; anything else that yields a managed reference is `&expr`, decompiled as the expression it is.
4. **Identify the fixed variable.** It is the unmanaged pointer local that the `conv.u` of the pinned local is stored into. Its declared pointer type (`uint8*`, `char*`, `int32*`) becomes the type in `fixed (T* p = …)`, and uses of it in the region are uses of `p`.
5. **Fold** — delete the guard, the temp, the pinned local, the `conv.u`, and the clearing store; emit `fixed (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.

