DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling C# 12 inline arrays and stackalloc: fixed buffers without the unsafe

C# 12 added inline arrays, and they are easy to misread even in source: a fixed-size buffer that lives inside its containing struct, value-type all the way down, no heap allocation, no array object. It is the managed, safe answer to the C programmer's int buf[8] — the thing you previously could only get with an unsafe fixed-size buffer. Paired with stackalloc, which has been quietly modernised to hand you a Span<T> instead of a raw pointer, you can now write buffer-heavy, allocation-free code without a single unsafe keyword.

That safety is a source-level illusion built by the compiler, and the illusion is exactly what a decompiler has to reconstruct. Underneath, an inline array is not an array — it is a one-field struct with an attribute that tells the runtime to lay out N copies of the field. Element access is not ldelem — it is a call to a runtime intrinsic that returns a managed reference. And stackalloc is not safe at all underneath — it is a localloc, the rawest stack allocation the CLR has, wrapped in a Span constructor so the pointer never reaches your eyes. A decompiler that lowers to the metal and stops shows you ref arithmetic, helper calls with names you cannot type, and unsafe blocks that were never in the source. The code it emits often will not even recompile.

This post walks the IL for both features and shows what a decompiler must recognise to put the source back together.

An inline array is a struct wearing an attribute

Here is the declaration and a trivial use:

[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer8
{
    private int _element0;
}

public static int Sum(Buffer8 buf)
{
    int total = 0;
    for (int i = 0; i < 8; i++)
        total += buf[i];
    return total;
}
Enter fullscreen mode Exit fullscreen mode

Buffer8 has one field, _element0, and one attribute, [InlineArray(8)]. That is the whole trick: the attribute instructs the runtime to allocate the field eight times contiguously, so a Buffer8 is 32 bytes and buf[3] means "the fourth int in that block." There is no array object and no length word — the length is baked into the attribute, known at compile time, and never stored at runtime.

In metadata the type is a plain sealed value type with a single field. Nothing in the type definition says "array"; the only signal is the custom attribute:

.class public sequential ansi sealed beforefieldinit Buffer8
    extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.InlineArrayAttribute::.ctor(int32)
        = ( 01 00 08 00 00 00 00 00 )          // InlineArray(8)
    .field private int32 _element0
}
Enter fullscreen mode Exit fullscreen mode

A decompiler that classifies types by their shape sees a one-field struct and, unless it reads that attribute, prints it as a one-field struct — losing the entire meaning. So the first recognition rule is: a value type carrying [InlineArray(N)] is an inline array of its single field's type, length N, and it must be rendered with the attribute and the single backing field exactly as the compiler emitted them.

Indexing lowers to a runtime intrinsic, not ldelem

Now the access. buf[i] compiles not to an array-load but to a call that produces a managed reference to element i. The compiler routes it through a generated helper in <PrivateImplementationDetails> that wraps RuntimeHelpers.InlineArrayElementRef (or builds a Span<T> over the buffer with InlineArrayAsSpan and indexes that, depending on context). The read inside the loop lowers roughly to:

// total += buf[i];
ldarga.s   buf
ldloc      i
call       !!1& [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InlineArrayElementRef<valuetype Buffer8, int32>(!!0&, int32)
ldind.i4
add
Enter fullscreen mode Exit fullscreen mode

The InlineArrayElementRef call takes a reference to the buffer and an index, and returns a byref to the element; ldind.i4 then dereferences it to load the int. For a write you would see stind.i4 against the same kind of ref. There is no ldelem, no bounds-check opcode pattern, nothing that looks like an array — just an intrinsic call returning a ref.

A naive decompiler renders that literally: a call to RuntimeHelpers.InlineArrayElementRef<Buffer8, int>(ref buf, i) followed by a dereference, or worse, a reference to a <PrivateImplementationDetails> helper whose name contains characters you cannot write in C#. Either way the output does not say buf[i] and does not recompile. The recognition rule is: a call to the inline-array element-ref intrinsic (or the generated helper around it) against an [InlineArray] type, followed by a load or store through the returned ref, is an indexer access — render it as buf[i]. The same applies to the InlineArrayAsSpan form, which a decompiler should fold back either into element access or into a Span<T> view of the buffer, depending on how the result is used.

stackalloc is a localloc wearing a Span

Now the second feature. Modern stackalloc assigned to a Span<T> looks completely safe in source:

public static int SumFirstFour(ReadOnlySpan<int> input)
{
    Span<int> scratch = stackalloc int[4];
    for (int i = 0; i < 4; i++)
        scratch[i] = input[i] * input[i];
    int total = 0;
    foreach (int v in scratch) total += v;
    return total;
}
Enter fullscreen mode Exit fullscreen mode

No unsafe, no pointer. But stackalloc has exactly one lowering: the localloc instruction, which allocates a block on the current method's stack frame and pushes a native pointer to it. The compiler sizes the block (4 * sizeof(int)), allocates, then constructs a Span<int> over the raw pointer and the element count using the Span<T>(void*, int) constructor:

// Span<int> scratch = stackalloc int[4];
ldc.i4.4
conv.u
ldc.i4.4
mul.ovf.un          // 4 elements * 4 bytes
localloc            // -> native int (pointer to the stack block)
ldc.i4.4
newobj instance void valuetype [System.Runtime]System.Span`1<int32>::.ctor(void*, int32)
stloc.0             // scratch
Enter fullscreen mode Exit fullscreen mode

That is the signature: an element-count ldc, a size multiply (conv.u / mul.ovf.un), a localloc, then a newobj on Span<T>::.ctor(void*, int32). A decompiler that renders those instructions literally produces an unsafe method with a void*, a multiplication, a localloc intrinsic (which C# cannot even express directly), and a Span constructor taking a pointer. None of that is in the source, and the void* forces an unsafe context the author deliberately avoided.

The recognition rule folds the whole shape into one expression: a localloc whose size is count * sizeof(T), consumed by Span<T> or ReadOnlySpan<T>'s (void*, int) constructor, is stackalloc T[count]. When the element type's size is a compile-time constant and the count is too, Glass reconstructs stackalloc int[4]; when the count is a variable, it reconstructs stackalloc int[n]. The raw pointer, the multiply, and the constructor all disappear back into the single stackalloc the author typed, and the method stays safe — no unsafe keyword, because the source had none.

There is a subtlety worth naming: not every stackalloc becomes a Span. The older form, int* p = stackalloc int[4];, assigns the pointer directly and is genuinely unsafe — there the void* and unsafe belong in the output, because they were in the source. The decompiler distinguishes the two by what consumes the localloc: a Span/ReadOnlySpan constructor means the safe form, a direct pointer assignment means the unsafe form. Getting that distinction right is the difference between faithfully reproducing a safe method and falsely accusing it of being unsafe.

Why faithful recovery matters here specifically

For most language features, a decompiler that gets the details slightly wrong produces code that is ugly but still builds. These two are different, because both features exist precisely to give you low-level performance while staying in safe, high-level C# — and the incorrect decompilation destroys exactly that property.

An inline array rendered as InlineArrayElementRef calls references helpers in <PrivateImplementationDetails>, a compiler-internal type whose members have names containing < and > that are illegal in C# source. That output cannot be pasted back and compiled; it is a description of the IL, not a reconstruction of the program. A stackalloc rendered as localloc plus a void* turns a safe method into one that requires unsafe, misrepresenting the method's safety contract and, again, often failing to compile without edits the author never made.

Glass reads both patterns at the level the author worked at. It classifies [InlineArray(N)] structs as inline arrays and renders their element access as indexers; it recognises the localloc-plus-Span-constructor shape as stackalloc and keeps the method safe, while still distinguishing the genuinely unsafe pointer form. The output reads like the source — buf[i] and stackalloc int[4] — and, just as importantly, recompiles like the source, which is the real test of whether a decompiler understood the program or merely transcribed its bytes.

Summary

Inline arrays and modern stackalloc are two of the clearest cases where the IL is dramatically lower-level than the C#. An inline array is a one-field struct whose [InlineArray(N)] attribute is the only evidence it is a buffer, indexed through runtime intrinsics that return byrefs rather than any array opcode. A Span-typed stackalloc is a raw localloc dressed in a Span<T>(void*, int) constructor so the pointer never shows. A decompiler earns its keep by recovering the author's intent: the inline-array declaration and its indexers, the single stackalloc expression, and the safe, unsafe-free method body that made these features worth using in the first place.

Top comments (0)