Write this:
int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
and the natural guess is that the compiler emits nine stores — load the array, load an index, load a value, stelem.i4, nine times over. For a short array it does roughly that. But past a handful of elements the C# compiler switches to something that looks nothing like the source: it allocates the array and then calls RuntimeHelpers.InitializeArray, handing it a pointer to a static field whose bytes are the whole array. The values are no longer in the code at all. They have been packed into a data section of the assembly, and the only thing left in the IL is a bulk copy.
That is a genuine problem for a decompiler, because there is no literal to read. The IL holds a memcpy, not { 2, 3, 5, ... }. Recovering the initializer means recognising the pattern, chasing a field token out of the instruction stream and into the file's raw bytes, and reinterpreting those bytes with a type the IL only mentions somewhere else. This post walks that chase for Glass.NET.
What the compiler actually emits
Here is the IL for the array above. The values are conspicuously absent:
IL_0000: ldc.i4.s 9
IL_0002: newarr [System.Runtime]System.Int32 // allocate int[9] — element type lives HERE
IL_0007: dup
IL_0008: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=36' '<PrivateImplementationDetails>'::'2BCA...E6'
IL_000d: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_0012: stloc.0
Four things carry the whole initializer, and no single one of them is enough on its own:
-
newarr [System.Runtime]System.Int32— the only place the element type appears. The blob that follows is untyped; this instruction is what says the bytes areInt32s. -
ldc.i4.s 9— the element count, which the decompiler can cross-check against the blob length. -
ldtoken ... '__StaticArrayInitTypeSize=36'— a handle to a synthetic static field. The name encodes the byte length: 9 ints x 4 bytes = 36. The second name (2BCA...E6) is a content hash, so two identical initializers anywhere in the assembly share one field. -
call RuntimeHelpers::InitializeArray— the signal. This exact call, taking anArrayand aRuntimeFieldHandle, is the pattern a decompiler watches for.
The three hops to the bytes
The field named by that ldtoken has no managed initializer. It is a static field of a synthetic value type — __StaticArrayInitTypeSize=36, a struct whose only meaningful attribute is [StructLayout(Size = 36)] — living in the hidden <PrivateImplementationDetails> type the compiler creates for exactly this purpose. What gives the field its value is a row in the FieldRVA metadata table, which maps the field's token to a relative virtual address inside the PE. At that address sit the 36 bytes:
02 00 00 00 03 00 00 00 05 00 00 00 07 00 00 00 0B 00 00 00
0D 00 00 00 11 00 00 00 13 00 00 00 17 00 00 00
Each group of four is one Int32, little-endian: 02 00 00 00 is 2, 0B 00 00 00 is 11, 17 00 00 00 is 23. So to recover the literal a decompiler makes three hops — token to field, field to FieldRVA row, FieldRVA to raw bytes — then slices the bytes using the type it read off the newarr.
What Glass reconstructs
Glass.NET recognises the InitializeArray call, resolves the RuntimeFieldHandle to the <PrivateImplementationDetails> field, reads its FieldRVA bytes, takes Int32 and its 4-byte width from the newarr, and walks the 36 bytes in little-endian quads. It renders exactly what was written:
int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
The element size is not a detail — it is what makes the numbers come out right. The same 16-byte blob 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 is { 1, 2, 3, 4 } when the newarr says Int32, but { 1, 2 } when it says Int64, because then each element is eight bytes read little-endian. Glass takes the width from the allocation, not a guess, so a long[], a double[] (where the eight bytes are an IEEE-754 bit pattern) and a char[] (two bytes of UTF-16 per element) each slice correctly. Shared blobs are handled too: because the field is keyed by a content hash, two identical initializers in different methods point at the same field, and Glass renders each call site as its own full literal rather than leaking the sharing into the output.
When the blob form does not appear
Two cases stay element-by-element, and a decompiler reads them straight off the IL with no FieldRVA hop:
string[] names = { "ada", "alan", "grace" }; // references — cannot be memcpy'd
int[] sized = new int[Count]; // not constant — no blob to copy
A string[] holds object references, not characters; the pointers do not exist until run time and are not constants, so there is nothing to pack. The compiler emits dup / ldc.i4 / ldstr / stelem.ref per element, which Glass reads back as the string values directly. The same is true of any reference-type array, and of any array whose elements are not compile-time constants — there is no constant blob, so the stores stay explicit. This is why the blob form is specifically a blittable-primitive-constant optimisation: it is the only case where the array's value is its bytes.
The span case: no array at all
C# has one more trick worth knowing, because its IL looks almost like the array form but allocates nothing:
ReadOnlySpan<byte> magic = [ 0x4D, 0x5A, 0x90, 0x00 ]; // PE header bytes, as a span
A ReadOnlySpan<byte> built from a constant byte sequence does not create an array on the heap. The compiler points the span straight at the FieldRVA data:
IL_0000: ldsflda field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4' ...
IL_0005: ldc.i4.4
IL_0006: call valuetype [System.Runtime]System.ReadOnlySpan`1<uint8> System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<uint8>(valuetype [System.Runtime]System.RuntimeFieldHandle)
There is no newarr and no InitializeArray — instead RuntimeHelpers.CreateSpan<byte> wraps a span over the field's bytes with zero copying, which is why this pattern only works for byte (and other single-byte or endianness-free element types where the in-memory layout is portable). Glass recognises CreateSpan the same way it recognises InitializeArray: resolve the field, read the FieldRVA bytes, and render the collection expression [ 0x4D, 0x5A, 0x90, 0x00 ]. The tell that distinguishes it from the array form is ldsflda + CreateSpan instead of newarr + InitializeArray — same data section, different front door, and no allocation behind it.
The takeaway
An array initializer of blittable constants does not survive into the IL as a list of values. The compiler allocates the array, packs the elements as raw little-endian bytes into a static field in <PrivateImplementationDetails>, gives that field its data through a FieldRVA metadata row rather than an instruction, and leaves behind a single RuntimeHelpers.InitializeArray call that memcpys the bytes in. So a decompiler cannot read the literal from the code — it has to recognise the call, resolve the field token, follow the FieldRVA into the data section, take the element type from the newarr that allocated the array, and slice the blob in element-sized little-endian chunks. Glass.NET does exactly that, and gets the width right so a long[] is not misread as twice as many ints; it reads the string and non-constant cases straight off their explicit stores; and it recognises the CreateSpan variant where a ReadOnlySpan<byte> points at the same data with no array at all. The values were always there — just three hops out of the instruction stream, in a section the IL only ever gestures at.
Top comments (0)