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

fixed dekompilieren: gepinnte Lokale, die Null-oder-leer-Prüfung für Arrays und GetPinnableReference

Es gibt keine fixed-Anweisung in IL. Was der C#-Compiler stattdessen emittiert, ist eine lokale Variable mit dem Modifizierer pinned in ihrer Signatur, ein paar Instruktionen, die aus dem, was Sie gepinnt haben, einen Zeiger berechnen — mit Prüfungen, die Sie nie geschrieben haben —, und ein Store, der die Lokale am Ende des Blocks löscht. Es gibt kein try/finally, denn ein Pin ist eine Eigenschaft der Lebensdauer einer Lokalen und keine Code-Region. Der Initialisierer wird je nachdem, was Sie gepinnt haben, auf vier verschiedene Arten abgesenkt: Ein Array bekommt eine Null-oder-leer-Prüfung und ein ldelema von Element null, ein String bekommt entweder die alte OffsetToStringData-Arithmetik oder einen Aufruf von GetPinnableReference, ein Span oder jeder Typ mit einer GetPinnableReference-Methode bekommt diesen Aufruf auf seiner Adresse, und &field oder &local bekommt ein schlichtes ldflda oder ldloca. Dieser Beitrag liest die echte IL für jede Form, erklärt, warum ein leeres Array einen Nullzeiger statt einer Ausnahme liefert, und zeigt, wie Glass.NET an der gepinnten Lokalen ansetzt, um die Maschinerie in das einzeilige fixed zurückzufalten, das Sie geschrieben haben.

fixed ist das kleinste Stück Syntax im Unsafe-Werkzeugkasten von C# und eines der interessanteren Dinge, die man in IL finden kann, denn fast nichts davon überlebt. Es gibt keinen fixed-Opcode. Es gibt keine pinned-Instruktion. Es gibt kein try/finally — anders als using und foreach, die eines für ihr Aufräumen erzeugen, hat ein fixed-Block keinen Aufräum-Aufruf zu tätigen. Was es stattdessen gibt, ist eine lokale Variable mit einem ungewöhnlichen Typmodifizierer, ein paar Instruktionen, die einen Zeiger berechnen, und ein Store, der die Lokale löscht, wenn Sie fertig sind.

Und der Initialisierer — das, was nach dem = kommt — wird je nachdem, was Sie gepinnt haben, auf vier verschiedene Arten abgesenkt. Ein Decompiler muss jede davon erkennen, um die eine Zeile auszugeben, die Sie geschrieben haben. Dieser Beitrag liest die IL für alle vier und zeigt, wie Glass.NET zurück zu fixed kommt.

Der Anker: eine Lokale mit dem Modifizierer pinned

Alles hängt an einem Stück Metadaten. Nehmen Sie den einfachsten Fall:

unsafe int First(byte[] data)
{
    fixed (byte* p = data)
    {
        return *p;
    }
}

Die Signatur der lokalen Variablen der Methode — der LocalVarSig-Blob, auf den der Methodenkopf zeigt — deklariert zwei Lokale:

.locals init (
    [0] uint8* p,
    [1] uint8& pinned
)

Lokale 1 ist eine verwaltete Referenz auf ein byte (uint8&), die die Einschränkung pinned trägt. pinned ist ein Elementtyp-Modifizierer in der Signaturkodierung (ELEMENT_TYPE_PINNED, 0x45), und er bedeutet für die Laufzeit genau eines: Solange diese Lokale eine Nicht-Null-Referenz hält, darf der Garbage Collector das Objekt, in das sie zeigt, nicht verschieben. Das ist der gesamte Mechanismus. Es gibt keine Instruktion, die pinnt; die Lokale pinnt, solange sie lebendig und nicht null ist.

Das ist auch der Grund, warum fixed ohne PDB wiederherstellbar ist. Der Modifizierer pinned ist Teil der Signatur-Metadaten der Methode, die die Laufzeit braucht und die daher in jeder Assembly ausgeliefert werden, mit oder ohne Debug-Symbole. Ein Decompiler, der eine gepinnte Lokale findet, weiß, dass hier ein fixed war; der Rest besteht darin, herauszufinden, was gepinnt wurde und wie lange.

Form 1: ein Array und die Prüfung, die Sie nie geschrieben haben

Hier ist der Rumpf von First, und er ist länger, als der Quelltext vermuten ließe:

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

Zwei Dinge darin stehen nicht in Ihrem Quelltext.

Das erste ist die Null-oder-leer-Prüfung. Sie haben fixed (byte* p = data) geschrieben, und der Compiler hat eine Null-Prüfung und eine Längenprüfung emittiert, bevor irgendetwas gepinnt wird. Der Grund ist die Sprachregel für Arrays: &data[0] von einem leeren Array zu nehmen würde eine IndexOutOfRangeException werfen, und die Spezifikation definiert stattdessen, dass fixed über ein Null- oder leeres Array einen Nullzeiger erzeugt. Der abgesenkte Code ist also dreigeteilt — Null-Array, leeres Array, echtes Array —, und nur der dritte Pfad führt tatsächlich ldelema aus und speichert in die gepinnte Lokale. Die beiden anderen speichern einen Nullzeiger in p und pinnen überhaupt nie. Das lohnt sich als Leser von unsicherem Code zu wissen: *p auf dem Pfad des leeren Arrays dereferenziert null, und der Fehler passiert bei der Verwendung, nicht am fixed.

Das zweite ist der löschende Store bei IL_001d–IL_001f. Der Compiler speichert am normalen Ende des Blocks eine Null-Referenz in die gepinnte Lokale. Das ist keine Korrektheitsanforderung — der Pin würde ohnehin enden, wenn die Lokale tot wird oder der Frame abgewickelt wird —, es ist der Compiler, der den Pin zügig beendet, damit der GC das Array komprimieren kann, sobald Sie fertig sind, statt erst beim Verlassen der Methode. In optimierten Builds, in denen auf den Block unmittelbar ret folgt, lässt der Compiler ihn manchmal weg, weshalb ein Decompiler den löschenden Store als optional behandeln muss: Sein Fehlen bedeutet „die Methode ist zu Ende“, nicht „das war kein fixed“.

Ein Pin ist eine Lebensdauer, keine Regionif (data == null || data.Length == 0) p = null; // vom Compiler ergänzte Prüfungelse { pinned = ref data[0]; p = (byte*)pinned; } // der Pin BEGINNT bei diesem Storesolange `pinned` eine Referenz hält, darf der GC `data` nicht verschieben... Rumpf: *p, p[i], p an nativen Code übergeben ...kein .try, kein Handler — nichts muss LAUFEN, wenn der Block endetpinned = null; // der Pin ENDET (optional, zügig)bei einer Ausnahme endet der Pin beim Abwickeln des Frames; bei `ret` stirbt die LokaleEin Decompiler, der hier nach einem finally sucht, findet keines — der Anker ist die Lebensdauer der gepinnten Lokalen.

Form 2: ein String, in zwei Generationen

Strings sind seit C# 1 pinnbar, und die Absenkung hat sich unterwegs geändert, sodass ein Decompiler für dieselbe Quelltextzeile zwei Formen antrifft:

unsafe int FirstChar(string s)
{
    fixed (char* p = s)
        return p == null ? -1 : *p;
}

Die alte Form — was Sie beim Zielen auf .NET Framework bekommen und was ältere Roslyn-Versionen überall emittiert haben — pinnt das String-Objekt selbst und rechnet mit Zeigerarithmetik zu seinen Zeichen:

.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

Die gepinnte Lokale ist hier vom Typ string — eine gepinnte Objektreferenz statt eines gepinnten verwalteten Zeigers —, und RuntimeHelpers.OffsetToStringData ist die Antwort der Laufzeit auf „wie viele Bytes vom Anfang eines String-Objekts bis zu seinem ersten char“. Der Compiler addiert das zur Objektadresse, um bei den Zeichen zu landen. Die Null-Prüfung ist da, weil OffsetToStringData zu einer Null-Adresse addiert einen unsinnigen Nicht-Null-Zeiger ergäbe; fixed über einen Null-String muss null liefern.

Die moderne Form — .NET Core 3.0 und neuer, wo string GetPinnableReference() bereitstellt — lässt die Arithmetik vollständig weg:

.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

Jetzt ist die gepinnte Lokale ein char& — ein verwalteter Zeiger direkt auf das erste Zeichen —, und die Methode, die ihn erzeugt hat, ist string.GetPinnableReference(), deren Rückgabetyp ref readonly char ist (das modreq(InAttribute) in der IL-Signatur ist die Schreibweise von ref readonly in Metadaten). Kein Offset, keine Arithmetik. Dieselbe Quelltextzeile, zwei völlig verschiedene Instruktionsfolgen, die ein Decompiler beide auf fixed (char* p = s) zurückführen muss. (Das genaue Sprunglayout variiert zwischen Roslyn-Versionen ein wenig; die Fingerabdrücke — die gepinnte Lokale, die Null-Prüfung, der GetPinnableReference-Aufruf oder die OffsetToStringData-Addition — nicht.)

Form 3: Spans und das GetPinnableReference-Muster

C# 7.3 hat die moderne String-Form zu einem Muster verallgemeinert: Jeder Typ mit einer zugänglichen GetPinnableReference()-Methode, die ref T oder ref readonly T zurückgibt, kann Operand von fixed sein, und Span<T>, ReadOnlySpan<T> und Memory<T>-nahe Typen haben alle eine.

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

Zwei Unterschiede zur String-Variante fallen auf. Der Empfänger ist ein Werttyp, also erfolgt der Aufruf auf ldarga.s values — der Adresse des Spans — mit einem schlichten call, und es gibt keine Null-Prüfung: Ein Struct kann nicht null sein, und Span<T>.GetPinnableReference() ist so spezifiziert, dass es für einen leeren Span selbst eine Null-Referenz zurückgibt (sodass p bei leerer Eingabe null wird, passend zur Array-Regel, aber durch den Vertrag des Aufgerufenen statt durch die Prüfung des Aufrufers). Die Aufgabe des Decompilers ist daher je Form leicht unterschiedlich: Bei einem Array muss er die Prüfung erkennen und absorbieren; bei einem Span muss er erkennen, dass das Fehlen einer Prüfung normal ist.

Das Muster gilt auch für Ihre eigenen Typen. Geben Sie einem Struct eine public ref byte GetPinnableReference() — oder liefern Sie eine als Erweiterungsmethode —, und fixed (byte* p = myBuffer) kompiliert zu demselben Tripel call/stloc pinned/conv.u. Ein Decompiler, der einen Aufruf einer Methode namens GetPinnableReference liest, deren Ergebnis in eine gepinnte Lokale gespeichert wird, kann ihn unabhängig vom deklarierenden Typ falten, was genau die Duck-Typing-Regel ist, die der Compiler verwendet hat.

Form 4: eine explizite Adresse

Die letzte Form ist die mit der geringsten Absenkung, denn Sie haben die Adresse selbst geliefert:

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 und ldflda erzeugen die verwaltete Referenz direkt; sie wandert in die gepinnte Lokale, dann per conv.u in den Zeiger. Keine Prüfung — der Indexer wirft von selbst, wenn 3 außerhalb des Bereichs liegt, und ein Feldzugriff auf ein Null-Objekt wirft von selbst —, also fügt der Compiler nichts hinzu. Ein Decompiler erkennt diese Form durch Ausschluss: Eine gepinnte Lokale, die aus einer adresserzeugenden Instruktion initialisiert wird, die weder Element null hinter einer Prüfung noch ein GetPinnableReference-Aufruf ist, ist ein explizites &expr, und der Ausdruck ist das, wozu die Adressberechnung dekompiliert.

Puffer fester Größe — fixed int Values[8]; innerhalb eines Structs — sind ein anderes Feature, das sich zufällig das Schlüsselwort teilt; sie erzeugen einen verschachtelten Puffertyp und ldflda dagegen und werden auf modernen Zielplattformen von Inline-Arrays verdrängt. Inline-Arrays und stackalloc dekompilieren behandelt diese Seite.

Wie Glass.NET es zurückfaltet

Nimmt man die vier Formen zusammen, ist die fixed-Wiederherstellung in Glass.NET an dem einen verankert, was alle gemeinsam haben, und wird dann nach dem Initialisierer verteilt.

OperandIL-Fingerabdruck (in eine gepinnte Lokale)Glass.NET gibt ausArraybrfalse / ldlen brtrue Prüfung,ldc.i4.0 ldelema T → stloc T& pinnedfixed (T* p = array)string (alt)stloc string pinned, conv.u,brfalse-Prüfung, OffsetToStringData addfixed (char* p = s)string / span / eigener Typ[Null-Prüfung bei Referenztyp]call GetPinnableReference → stloc T& pinnedfixed (T* p = operand)&exprldflda / ldloca / ldelema (Index ≠ 0 oderungeprüft) → stloc T& pinnedfixed (T* p = &expr)

Die Schritte, der Reihe nach:

  1. Die gepinnten Lokalen finden. Sie sind in der Signatur der lokalen Variablen deklariert, das ist also ein Metadaten-Lesen, keine Schlussfolgerung. Jede gepinnte Lokale ist ein fixed-Kandidat.
  2. Die Region abgrenzen. Sie beginnt beim ersten Store eines Nicht-Null-Werts in die gepinnte Lokale und endet bei dem Store, der sie löscht — ldc.i4.0; conv.u; stloc für eine gepinnte Referenz, ldnull; stloc für ein gepinntes Objekt — oder, wenn kein löschender Store existiert, beim Verlassen der Methode. Roslyn verwendet gepinnte Slots für aufeinanderfolgende fixed-Anweisungen in derselben Methode wieder, sodass eine gepinnte Lokale mehrere Regionen abgrenzen kann; jeder löschende Store schließt eine, und der nächste Nicht-Null-Store öffnet die nächste.
  3. Den Initialisierer klassifizieren anhand der Instruktion, die den in die gepinnte Lokale gespeicherten Wert erzeugt hat: ldelema mit konstantem Index 0 hinter einer Null-/Längenprüfung ist ein Array; ein GetPinnableReference-Aufruf (beliebiger deklarierender Typ) ist die moderne String/Span/Eigener-Typ-Form, wobei die Prüfung absorbiert wird, wenn der Empfänger ein Referenztyp ist; OffsetToStringData-Arithmetik auf einem gepinnten string ist die alte String-Form; alles andere, das eine verwaltete Referenz liefert, ist &expr, dekompiliert als der Ausdruck, der es ist.
  4. Die fixed-Variable identifizieren. Es ist die Lokale für den unverwalteten Zeiger, in die das conv.u der gepinnten Lokalen gespeichert wird. Ihr deklarierter Zeigertyp (uint8*, char*, int32*) wird zum Typ in fixed (T* p = …), und Verwendungen davon in der Region sind Verwendungen von p.
  5. Falten — die Prüfung, die Temporäre, die gepinnte Lokale, das conv.u und den löschenden Store löschen; fixed (T* p = operand) { region } ausgeben.

Wenn eine Form nicht zusammenpasst — die gepinnte Lokale wird innerhalb der Region um ihres eigenen Werts willen gelesen, die Prüfung testet etwas anderes als den gepinnten Operanden, eine Zeiger-Lokale wird aus zwei verschiedenen gepinnten Lokalen zugewiesen —, verzichtet Glass.NET auf das Falten und zeigt stattdessen die abgesenkte Form, mit der gepinnten Lokalen als // pinned annotiert. Es gibt keine C#-Syntax für eine gepinnte Lokale, also ist die Annotation die ehrliche Darstellung; ein fixed, das falsch darstellt, was gepinnt ist oder wie lange, wäre schlimmer als ein ungefaltetes.

Warum das zählt, wenn Sie fremden Code lesen

fixed taucht genau in dem Code auf, den Sie am sorgfältigsten lesen wollen: P/Invoke-Marshalling, handgeschriebene Parser über Span<byte>, Bild- und Audio-Codecs, alles, was einen Zeiger an nativen Code übergibt. Drei Details der Absenkung sind das, was Sie beim Review tatsächlich prüfen. Der Nullzeiger beim leeren Array ist eine klassische Fehlerklasse — der Zeiger ist null, und die erste Dereferenzierung schlägt fehl, weit entfernt vom fixed. Die Pin-Lebensdauer ist das zweite: Der Pin endet, wenn die Lokale gelöscht oder der Frame abgewickelt wird, sodass ein Zeiger, der aus dem Block entkommt — in einem Feld gespeichert, an einen nativen Callback übergeben, der später feuert — in Speicher zeigt, den der GC verschieben darf, sobald die Methode zurückkehrt. Und das Fehlen einer Prüfung bei der Span-Form bedeutet, dass der Vertrag des Aufgerufenen, nicht Ihr Code, den leeren Fall sicher hält.

Ein Decompiler, der fixed (byte* p = data) ausgibt, hat Ihnen die Absicht gezeigt. Ein Decompiler, der Ihnen auch die abgesenkte Form zeigen kann — die Prüfung, die gepinnte Lokale, den löschenden Store —, hat Ihnen gezeigt, was die Laufzeit tatsächlich damit tun wird, und für unsicheren Code ist das das, worauf es ankommt.

Nebula.NET testen

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