Skip to content
← Alle Beiträge
· Delta1 Labs Glass.NETDekompilierung.NETTiefenanalyse

Inline Arrays und stackalloc in C# 12 dekompilieren: Puffer fester Größe ohne das unsafe

Inline Arrays in C# 12 geben Ihnen einen Puffer fester Größe vom Werttyp, der inline in seinem umgebenden Struct lebt – das, was C `int buf[8]` nannte –, aber im IL ist es ein Struct mit einem einzigen Feld, dekoriert mit [InlineArray(8)], zugegriffen über Runtime-Intrinsics und Span<T> statt über eine Array-Instruktion. stackalloc wiederum kompiliert zu einem rohen localloc, das ein naiver Dekompiler als Zeiger und einen Haufen stind-Opcodes zeigt. Irren Sie sich bei einem von beiden, und der Dekompiler gibt unsafe-Zeigerarithmetik aus, wo die Quelle einen sauberen Indexer hatte, oder erfindet ein Array, das nie alloziert wurde. Hier ist genau, was der Compiler für einen [InlineArray]-Typ ausgibt, wie der Elementzugriff zu einem PrivateImplementationDetails-Helfer über einem Span absteigt, wie localloc und der Span(void*, int)-Konstruktor im IL aussehen, und wie Glass.NET beides zurück in die Inline-Array-Deklarationen und stackalloc-Ausdrücke liest, die Sie tatsächlich geschrieben haben.

C# 12 fügte Inline Arrays hinzu, und sie sind selbst in der Quelle leicht misszuverstehen: ein Puffer fester Größe, der innerhalb seines umgebenden Structs lebt, durch und durch Werttyp, ohne Heap-Allokation, ohne Array-Objekt. Es ist die verwaltete, sichere Antwort auf das int buf[8] des C-Programmierers: das, was Sie zuvor nur mit einem unsafe-Puffer fester Größe bekommen konnten. Gepaart mit stackalloc, das still modernisiert wurde, um Ihnen einen Span<T> statt eines rohen Zeigers zu reichen, können Sie nun pufferlastigen, allokationsfreien Code ohne ein einziges unsafe-Schlüsselwort schreiben.

Diese Sicherheit ist eine Illusion auf Quellebene, vom Compiler gebaut, und die Illusion ist genau das, was ein Dekompiler rekonstruieren muss. Darunter ist ein Inline Array kein Array: Es ist ein Struct mit einem einzigen Feld und einem Attribut, das der Laufzeit sagt, N Kopien des Feldes auszulegen. Der Elementzugriff ist kein ldelem: Es ist ein Aufruf eines Runtime-Intrinsic, der eine verwaltete Referenz zurückgibt. Und stackalloc ist darunter gar nicht sicher: Es ist ein localloc, die roheste Stapelallokation, die die CLR hat, eingewickelt in einen Span-Konstruktor, damit der Zeiger nie Ihre Augen erreicht. Ein Dekompiler, der zum Metall absteigt und dort stehen bleibt, zeigt Ihnen ref-Arithmetik, Aufrufe von Helfern mit Namen, die Sie nicht tippen können, und unsafe-Blöcke, die nie in der Quelle waren. Der Code, den er ausgibt, rekompiliert oft nicht einmal.

Dieser Beitrag durchläuft den IL beider Funktionen und zeigt, was ein Dekompiler erkennen muss, um die Quelle wieder zusammenzusetzen.

Ein Inline Array ist ein Struct, das ein Attribut trägt

Hier die Deklaration und eine triviale Verwendung:

[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;
}

Buffer8 hat ein Feld, _element0, und ein Attribut, [InlineArray(8)]. Das ist der ganze Trick: Das Attribut weist die Laufzeit an, das Feld achtmal zusammenhängend zu allozieren, sodass ein Buffer8 32 Byte ist und buf[3] „das vierte int in diesem Block” bedeutet. Es gibt kein Array-Objekt und kein Längenwort: Die Länge ist ins Attribut eingebacken, zur Übersetzungszeit bekannt, und wird nie zur Laufzeit gespeichert.

In den Metadaten ist der Typ ein schlichter sealed value type mit einem einzigen Feld. Nichts in der Typdefinition sagt „Array”; das einzige Signal ist das benutzerdefinierte Attribut:

.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
}

Ein Dekompiler, der Typen nach ihrer Form klassifiziert, sieht ein Struct mit einem Feld und druckt es, sofern er dieses Attribut nicht liest, als Struct mit einem Feld – wobei die ganze Bedeutung verloren geht. Die erste Erkennungsregel lautet also: ein Werttyp, der [InlineArray(N)] trägt, ist ein Inline Array des Typs seines einzigen Feldes, Länge N, und er muss mit dem Attribut und dem einzigen Hintergrundfeld genau so gerendert werden, wie der Compiler sie ausgegeben hat.

Die Indizierung steigt zu einem Runtime-Intrinsic ab, nicht zu ldelem

Nun der Zugriff. buf[i] kompiliert nicht zu einem Array-Laden, sondern zu einem Aufruf, der eine verwaltete Referenz auf Element i erzeugt. Der Compiler leitet ihn über einen in <PrivateImplementationDetails> generierten Helfer, der RuntimeHelpers.InlineArrayElementRef umhüllt (oder mit InlineArrayAsSpan einen Span<T> über den Puffer baut und diesen indiziert, je nach Kontext). Das Lesen in der Schleife steigt ungefähr ab zu:

// 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

Der Aufruf von InlineArrayElementRef nimmt eine Referenz auf den Puffer und einen Index und gibt eine byref auf das Element zurück; ldind.i4 dereferenziert sie dann, um das int zu laden. Für ein Schreiben sähen Sie stind.i4 gegen dieselbe Art ref. Kein ldelem, kein Grenzprüfungs-Opcode, nichts, das wie ein Array aussieht – nur ein Intrinsic-Aufruf, der eine ref zurückgibt.

Ein naiver Dekompiler rendert das wörtlich: einen Aufruf von RuntimeHelpers.InlineArrayElementRef<Buffer8, int>(ref buf, i) gefolgt von einer Dereferenzierung, oder schlimmer, eine Referenz auf einen <PrivateImplementationDetails>-Helfer, dessen Name Zeichen enthält, die Sie in C# nicht schreiben können. In beiden Fällen sagt die Ausgabe nicht buf[i] und rekompiliert nicht. Die Erkennungsregel lautet: ein Aufruf des Inline-Array-Element-ref-Intrinsic (oder des darum generierten Helfers) gegen einen [InlineArray]-Typ, gefolgt von einem Laden oder Speichern über die zurückgegebene ref, ist ein Indexer-Zugriff – rendern Sie ihn als buf[i]. Das Gleiche gilt für die InlineArrayAsSpan-Form, die ein Dekompiler je nach Verwendung des Ergebnisses entweder in einen Elementzugriff oder in eine Span<T>-Ansicht des Puffers zurückfalten sollte.

Quellebuf[3]IL[InlineArray(8)] struct { int _element0 }call InlineArrayElementRef(ref,3) → ldind.i4naiver DekompilerInlineArrayElementRef(ref buf,3)referenziert <PrivateImplementationDetails> — baut nichtGlass.NETbuf[3] // über Buffer8gewinnt den Indexer zurück — rekompiliert

stackalloc ist ein localloc, das einen Span trägt

Nun die zweite Funktion. Ein modernes stackalloc, einem Span<T> zugewiesen, sieht in der Quelle völlig sicher aus:

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;
}

Kein unsafe, kein Zeiger. Aber stackalloc hat genau eine Absenkung: die localloc-Instruktion, die einen Block auf dem Stapelrahmen der aktuellen Methode alloziert und einen nativen Zeiger darauf pusht. Der Compiler dimensioniert den Block (4 * sizeof(int)), alloziert, baut dann mit dem Konstruktor Span<T>(void*, int) einen Span<int> über dem rohen Zeiger und der Elementanzahl:

// 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

Das ist die Signatur: ein Elementanzahl-ldc, eine Größen-Multiplikation (conv.u / mul.ovf.un), ein localloc, dann ein newobj auf Span<T>::.ctor(void*, int32). Ein Dekompiler, der diese Instruktionen wörtlich rendert, produziert eine unsafe-Methode mit einem void*, einer Multiplikation, einem localloc-Intrinsic (das C# nicht einmal direkt ausdrücken kann) und einem Span-Konstruktor, der einen Zeiger nimmt. Nichts davon ist in der Quelle, und der void* erzwingt einen unsafe-Kontext, den der Autor bewusst vermied.

Die Erkennungsregel faltet die ganze Form in einen Ausdruck: ein localloc, dessen Größe count * sizeof(T) ist, verbraucht vom (void*, int)-Konstruktor von Span<T> oder ReadOnlySpan<T>, ist stackalloc T[count]. Wenn die Größe des Elementtyps eine Übersetzungszeitkonstante ist und die Anzahl ebenfalls, rekonstruiert Glass stackalloc int[4]; ist die Anzahl eine Variable, rekonstruiert es stackalloc int[n]. Der rohe Zeiger, die Multiplikation und der Konstruktor verschwinden alle zurück in das eine stackalloc, das der Autor tippte, und die Methode bleibt sicher: kein unsafe-Schlüsselwort, weil die Quelle keines hatte.

Es gibt eine Feinheit, die man benennen sollte: Nicht jedes stackalloc wird zu einem Span. Die ältere Form, int* p = stackalloc int[4];, weist den Zeiger direkt zu und ist echt unsafe: Dort gehören der void* und das unsafe in die Ausgabe, weil sie in der Quelle waren. Der Dekompiler unterscheidet die beiden danach, was das localloc verbraucht: Ein Span/ReadOnlySpan-Konstruktor bedeutet die sichere Form, eine direkte Zeigerzuweisung die unsafe-Form. Diese Unterscheidung richtig zu treffen ist der Unterschied zwischen dem treuen Reproduzieren einer sicheren Methode und dem fälschlichen Beschuldigen, sie sei unsafe.

Warum die treue Rückgewinnung gerade hier zählt

Für die meisten Sprachfunktionen produziert ein Dekompiler, der sich bei den Details leicht irrt, Code, der hässlich ist, aber dennoch baut. Diese beiden sind anders, denn beide existieren gerade dazu, Ihnen Low-Level-Leistung zu geben, während Sie in sicherem Hochsprachen-C# bleiben – und die inkorrekte Dekompilierung zerstört genau diese Eigenschaft.

Ein als InlineArrayElementRef-Aufrufe gerendertes Inline Array referenziert Helfer in <PrivateImplementationDetails>, einem compilerinternen Typ, dessen Mitglieder Namen mit < und > tragen, die im C#-Quelltext unzulässig sind. Diese Ausgabe lässt sich nicht zurückfügen und kompilieren; sie ist eine Beschreibung des IL, keine Rekonstruktion des Programms. Ein als localloc plus void* gerendertes stackalloc verwandelt eine sichere Methode in eine, die unsafe erfordert, stellt den Sicherheitsvertrag der Methode falsch dar und scheitert, wiederum, oft daran zu kompilieren, ohne Änderungen, die der Autor nie vornahm.

Glass liest beide Muster auf der Ebene, auf der der Autor arbeitete. Es klassifiziert [InlineArray(N)]-Structs als Inline Arrays und rendert ihren Elementzugriff als Indexer; es erkennt die Form localloc-plus-Span-Konstruktor als stackalloc und hält die Methode sicher, während es die echt unsafe Zeigerform weiterhin unterscheidet. Die Ausgabe liest sich wie die Quelle – buf[i] und stackalloc int[4] – und, ebenso wichtig, rekompiliert wie die Quelle, was der wahre Test dafür ist, ob ein Dekompiler das Programm verstanden oder nur seine Bytes transkribiert hat.

Zusammenfassung

Inline Arrays und modernes stackalloc sind zwei der klarsten Fälle, in denen der IL dramatisch tiefer liegt als das C#. Ein Inline Array ist ein Struct mit einem Feld, dessen [InlineArray(N)]-Attribut der einzige Beweis ist, dass es ein Puffer ist, indiziert über Runtime-Intrinsics, die byrefs statt eines Array-Opcodes zurückgeben. Ein Span-typisiertes stackalloc ist ein rohes localloc, gekleidet in einen Span<T>(void*, int)-Konstruktor, damit der Zeiger sich nie zeigt. Ein Dekompiler verdient seinen Lohn, indem er die Absicht des Autors zurückgewinnt: die Inline-Array-Deklaration und ihre Indexer, den einen stackalloc-Ausdruck, und den sicheren, unsafe-freien Methodenrumpf, der diese Funktionen überhaupt erst nutzenswert machte.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.