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

ref-Felder und scoped dekompilieren: wie C#-11-ref-structs im IL aussehen

Ein ref-Feld ist das erste Mal, dass ein C#-Feld eine Referenz statt eines Werts speichert, und es kompiliert zu Metadaten, die frühere Dekompilierer nie lesen mussten. Die Signatur des Feldes trägt einen byref-Elementtyp — ELEMENT_TYPE_BYREF innerhalb einer FIELD-Signatur, was vor .NET 7 unzulässig war — und die ref-Sicherheitsregeln, die es daran hindern, das zu überleben, worauf es zeigt, sind in modreq und Attributen kodiert, nicht in ausführbarem IL. scoped ist das lauteste davon: es ist ein ScopedRefAttribute auf einem Parameter, ganz ohne Laufzeitwirkung, das ein Dekompilierer entweder als das Schlüsselwort scoped anzeigt oder fallen lässt, womit er stillschweigend den ref-Sicherheitsvertrag der Methode verändert. Dieser Beitrag liest die rohen Signaturen und Attribut-Blobs eines ref-Feldes, einer ref-struct, die eines enthält, und eines scoped-ref-Parameters, zeigt, wo im Metadatensatz die ref-Escape-Regeln leben, und geht durch, wie Glass.NET ref, ref readonly, scoped und die Notausstieg-Angabe [UnscopedRef] rekonstruiert, damit der dekompilierte Quellcode mit derselben Sicherheit kompiliert, die der Autor geschrieben hat.

Ein ref-Feld ist die erste C#-Funktion, die ein Feld eine Referenz statt eines Werts enthalten lässt, und die erste, die eine Änderung an den eigenen Regeln der Laufzeit erzwang, was eine Feldsignatur enthalten darf. Vor .NET 7 konnte ein Feld kein verwalteter Zeiger sein; die Laufzeit wies die Metadaten zurück. ref struct-Typen wie Span<T> täuschten es mit einem internen Intrinsic vor. C# 11 und .NET 7 machten es echt — und erzeugten dabei Metadaten, die für älteres IL geschriebene Dekompilierer schlicht nie analysieren mussten.

Das Problem ist, dass das meiste von dem, was ein ref-Feld sicher macht, gar nicht in ausführbarem IL steht. Ein ref-Feld kann auf eine lokale Variable zeigen, die aus dem Gültigkeitsbereich fällt; die Regeln, die das verhindern — die ref-Escape-Analyse — laufen vollständig im Compiler und hinterlassen ihre Spuren in Metadaten, die der JIT ignoriert: ein modreq hier, ein ScopedRefAttribute dort, ein [UnscopedRef] auf der einen Methode, die den Standard brechen darf. Ein Dekompilierer, der nur die Instruktionen liest, erzeugt Code, der identisch läuft und einen anderen Sicherheitsvertrag ausdrückt — und du entdeckst den Unterschied erst, wenn du ihn neu kompilierst und der Compiler einen Escape erlaubt, den der Autor verbot, oder eine Rückgabe verbietet, die der Autor beabsichtigte.

Dieser Beitrag liest die rohen Metadaten dreier Dinge — eines ref-Feldes, einer ref-struct, die eines speichert, und eines scoped-Parameters — und zeigt, wo die Sicherheit lebt und wie Glass.NET sie zurück in die richtigen Schlüsselwörter verwandelt.

ref-Feld — in der Signaturordinary: FIELD I4ref int: FIELD BYREF I4das führende BYREF ist das ref — vor .NET 7 unzulässigscoped — in einem Attributparam: BYREF T + [ScopedRef] (no runtime effect)weglassen → die ref darf entkommen: schwächerer Vertrag[UnscopedRef] — der Notausstiegmethod: ref int GetRef() + [UnscopedRef] on the methodweglassen → die ref-Rückgabe kompiliert nicht mehrwas ein nur-byref-Lesen ausgibtpublic int field; ← ref verlorenvoid M(ref T x) ← scoped verlorenläuft gleich; der ref-Sicherheitsvertrag hat sich geändert

Ein ref-Feld ist ein BYREF in einer FIELD-Signatur

Beginne mit der kleinstmöglichen ref-struct, die ein ref-Feld enthält, und lies, was der Compiler ausgibt.

public ref struct IntRef
{
    public ref int Value;              // a ref field

    public IntRef(ref int target) => Value = ref target;   // ref assignment
}

Die Felddeklaration kompiliert zu einem Feld, dessen Signatur nicht das übliche „FIELD dann Elementtyp” ist. Es ist FIELD, dann ELEMENT_TYPE_BYREF, dann der Elementtyp:

.field public int32& Value
//              ^^^^^^ the '&' is ELEMENT_TYPE_BYREF — a managed reference, in a FIELD sig

// signature bytes:  06 10 08
//   06 = FIELD
//   10 = ELEMENT_TYPE_BYREF
//   08 = ELEMENT_TYPE_I4   (int)

Dieses Byte 10 — ein BYREF innerhalb einer Feldsignatur — ist die ganze Funktion auf Metadatenebene, und es ist genau das, was eine Laufzeit vor .NET 7 zu laden ablehnte. Ein älterer Dekompilierer, der eine Feldsignatur durchläuft und FIELD <Typ> erwartet, liest das 10 als den Elementtyp einer fehlerhaften Signatur, oder überspringt es, und zeigt das Feld als schlichtes int32 Value. Der entstehende Quellcode deklariert ein Wertfeld, kein ref-Feld — er lässt die eine Eigenschaft fallen, die IntRef überhaupt wert macht, geschrieben zu werden, und er wird innerhalb einer ref struct nicht als derselbe Typ kompilieren.

Die ref-Zuweisung im Konstruktor ist ihre eigene Instruktion, verschieden von einem Wertspeicher:

// Value = ref target;
ldarg.0            // this
ldarg.1            // ref target  (already a managed pointer)
stfld   int32& IntRef::Value       // store the REFERENCE into the ref field

Das stfld zielt auf ein Feld, dessen Typ int32& ist. Vergleiche mit der Wertversion, this.Value = target, die zuerst ein ldind.i4 täte, um target zu dereferenzieren, und dann stfld int32 in ein Wertfeld. Glass liest die byref-Signatur des Feldes, erkennt das stfld in ein byref-Feld als ref-Zuweisung und rekonstruiert Value = ref target statt Value = target — zwei Anweisungen, die sich um ein Schlüsselwort unterscheiden und völlig verschiedene Dinge über die Lebensdauer bedeuten.

Die ref-struct-Marker darum herum

IntRef selbst trägt die Metadaten, die sagen „dies ist eine ref-struct”: es ist ein Werttyp, markiert mit zwei Attributen, und ein Dekompilierer braucht beide, um den ref struct-Modifikator und die Obsolet-Wache zu regenerieren, die alte Compiler fernhält.

.class public sequential ansi sealed beforefieldinit IntRef
       extends [System.Runtime]System.ValueType
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor()
  .custom instance void [System.Runtime]System.ObsoleteAttribute::.ctor(string, bool)
          = {string('Types with embedded references...') bool(true)}
}

IsByRefLikeAttribute ist der eigentliche Marker — es ist das, wozu ref struct kompiliert, und es ist das, was dem Compiler sagt, dass der Typ nicht geboxt werden kann, kein Feld einer Nicht-ref-struct sein kann, nicht erfasst werden kann und so weiter. Das [Obsolete(..., error: true)] mit dieser spezifischen Nachricht ist eine Wache, die der Compiler ausgibt, damit ein Compiler, der zu alt ist, um byref-artige Typen zu verstehen, sich weigert, ihn anzurühren, statt ihn stillschweigend falsch zu behandeln — dasselbe fehlgeschlossene Muster, das du bei required-Mitgliedern siehst. Glass bildet IsByRefLikeAttribute zurück auf das ref in ref struct ab und erkennt das gepaarte synthetische [Obsolete] als compilergeneriert, also bringt es das Attribut nicht zur Anzeige, als hätte der Autor es geschrieben — aber es bewahrt das ref struct-Schlüsselwort, das es erzeugt hat.

scoped: ein Vertrag ohne Instruktionen

Nun der Teil, den keine Menge IL-Lesens wiederherstellen wird, denn es gibt kein IL zu lesen. scoped schränkt ein, wohin eine ref reisen darf, und diese Einschränkung wird nur zur Kompilierzeit geprüft.

public static ref int Risky(scoped ref int x, ref int y)
{
    // return ref x;   // compile ERROR: x is scoped, may not escape
    return ref y;      // ok: y is not scoped
}

scoped auf dem Parameter x kompiliert zu einem Markierungsattribut auf diesem Parameter und ändert kein Byte des Methodenkörpers:

.method public static int32& Risky(
        int32& x,
        int32& y) cil managed
{
  .param [1]
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ScopedRefAttribute::.ctor()
  // ^ parameter 1 (x) carries [ScopedRef]; parameter 2 (y) does not
  ...
}

Sowohl x als auch y sind int32& in der Signatur — identische byref-Parameter, was die Instruktionen betrifft. Das Einzige, was scoped ref von schlichtem ref unterscheidet, ist das ScopedRefAttribute auf .param [1]. Der JIT ignoriert es vollständig; die Methode läuft gleich, mit oder ohne. Aber für den C#-Compiler ist es ein Versprechen, dass x nicht entkommt, was es Aufrufern erlaubt, eine Referenz auf eine kurzlebige lokale Variable sicher als x zu übergeben.

Ein Dekompilierer, der die Parameter allein aus der Signatur auflistet, sieht zwei ref int-Parameter und gibt Risky(ref int x, ref int y) aus. Das kompiliert und läuft identisch — aber es hat den Vertrag stillschweigend erweitert: die regenerierte Methode erlaubt nun return ref x, einen Escape, den das Original ausdrücklich verbot. Niemand bemerkt es, bis jemand den dekompilierten Quellcode bearbeitet, ein return ref x hinzufügt und der Compiler, der es hätte zurückweisen sollen, es akzeptiert, weil das scoped, das es gestoppt hätte, weg ist. Glass liest die benutzerdefinierten Attribute von .param neben der Signatur und stellt scoped genau auf den Parametern wieder her, die ScopedRefAttribute tragen.

ref, in und ref readonly teilen ein BYREF

Dieselbe Disziplin „lies den Modifikator, nicht nur das byref” trennt die drei leseorientierten byref-Arten, die in der rohen Signatur nicht unterscheidbar sind.

void A(ref int x);           // BYREF, no modifier
void B(in int x);            // BYREF + modreq(InAttribute), param has [IsReadOnly]
void C(ref readonly int x);  // BYREF + IsReadOnly signal, treated as ref-readonly (C# 12)

Alle drei Parameter sind int32&. in fügt der Signatur modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) hinzu und markiert den Parameter [IsReadOnly]; ref readonly trägt das Schreibgeschützt-Signal ohne die in-spezifische Nachsicht an der Aufrufstelle, sodass der Compiler warnen kann, wenn ein Argument in einer Form übergeben wird, die in stillschweigend akzeptiert hätte. Ein Dekompilierer, der nur das BYREF liest, fasst alle drei zu ref zusammen, was die Signatur der Überladung und die Regeln an jeder Aufrufstelle ändert. Glass prüft das modreq und das [IsReadOnly]-Attribut zusammen und wählt entsprechend ref, in oder ref readonly.

[UnscopedRef]: der Notausstieg, der überleben muss

scoped zieht an; [UnscopedRef] lockert — und es fallen zu lassen bricht die Kompilierung, statt einen Vertrag stillschweigend zu schwächen, was es zu dem einen davon macht, den ein Dekompilierer sich absolut nicht leisten kann zu verpassen.

Manche refs sind implizit gescopt. Das this einer struct-Instanzmethode ist eine davon: standardmäßig darfst du keine ref auf ein Feld der struct zurückgeben, denn die struct könnte ein Temporär sein. Eine struct, die eine ref in sich selbst herausgeben muss, markiert die Methode [UnscopedRef]:

public struct Buffer
{
    private int _first;

    [UnscopedRef]                       // without this, the return does not compile
    public ref int First() => ref _first;
}

In den Metadaten trägt die Methode UnscopedRefAttribute:

.method public hidebysig instance int32& First() cil managed
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.UnscopedRefAttribute::.ctor()
  // body: ldarg.0; ldflda int32 Buffer::_first; ret
}

Wenn ein Dekompilierer dieses Attribut fallen lässt, wird das regenerierte ref int First() => ref _first; vom Compiler zurückgewiesen — das Standard-Scoping auf this verbietet die Rückgabe einer ref auf _first. Also ist anders als bei scoped, wo der Verlust eine stillschweigende Erweiterung ist, der Verlust von [UnscopedRef] ein lauter Kompilierfehler in dem Moment, in dem du neu zu bauen versuchst. So oder so stimmt der dekompilierte Quellcode nicht mehr mit dem Programm des Autors überein. Glass bringt [UnscopedRef] genau dort zur Anzeige, wo es erscheint, denn hier ist das Attribut keine Dekoration — es ist das, was die Methode legal macht, und der regenerierte Typ macht ohne es nicht den vollen Rundlauf.

Alles zusammen lesen

Der rote Faden durch ref-Felder, scoped, in/ref readonly und [UnscopedRef] ist, dass C#s ref-Sicherheitsmodell an drei Orten lebt, die den JIT nicht kümmern: das BYREF in einer Signatur (die ref selbst), das modreq daneben (in gegen schlichtes ref) und die Attribute darum herum (ScopedRefAttribute, IsReadOnlyAttribute, UnscopedRefAttribute). Ein Dekompilierer, der nur die ausführbaren Instruktionen liest, bekommt Code, der läuft, und einen Sicherheitsvertrag, der subtil oder vollständig falsch ist — neu zuweisbar, wo das Original einmalig war, entkommbar, wo das Original gescopt war, oder nicht kompilierbar, wo das Original in Ordnung war. Glass.NET liest die Signatur, die benutzerdefinierten Modifikatoren und die Parameter- und Methodenattribute als eine Einheit, was es braucht, um dir ref struct, ref int, scoped ref, ref readonly und [UnscopedRef] so zurückzugeben, wie der Autor sie geschrieben hat — Quellcode, der zugleich gleich läuft und dieselben ref-Sicherheitsgarantien trägt.

Nebula.NET testen

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