Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilation.NETAnalyse approfondie

Décompiler les champs ref et scoped : à quoi ressemblent les ref struct de C# 11 en IL

Un champ ref est la première fois qu'un champ C# stocke une référence plutôt qu'une valeur, et il se compile en métadonnées que les décompilateurs antérieurs n'ont jamais eu à lire. La signature du champ porte un type d'élément byref — ELEMENT_TYPE_BYREF à l'intérieur d'une signature FIELD, ce qui était illégal avant .NET 7 — et les règles de sûreté ref qui l'empêchent de survivre à ce qu'il pointe sont encodées dans des modreq et des attributs, pas dans de l'IL exécutable. scoped est le plus bruyant d'entre eux : c'est un ScopedRefAttribute sur un paramètre, sans aucun effet à l'exécution, qu'un décompilateur affiche soit comme le mot-clé scoped, soit laisse tomber, changeant silencieusement le contrat de sûreté ref de la méthode. Ce billet lit les signatures brutes et les blobs d'attributs d'un champ ref, d'une ref struct qui en contient un, et d'un paramètre scoped ref, montre où vivent les règles d'échappement de ref dans les métadonnées, et parcourt comment Glass.NET reconstruit ref, ref readonly, scoped et la porte de sortie [UnscopedRef] pour que le code source décompilé compile avec la même sûreté que celle écrite par l'auteur.

Un champ ref est la première fonctionnalité C# qui laisse un champ contenir une référence au lieu d’une valeur, et la première qui a forcé un changement dans les propres règles du runtime quant à ce qu’une signature de champ peut contenir. Avant .NET 7 un champ ne pouvait pas être un pointeur géré ; le runtime rejetait les métadonnées. Les types ref struct comme Span<T> le simulaient avec un intrinsèque interne. C# 11 et .NET 7 l’ont rendu réel — et ce faisant ont créé des métadonnées que les décompilateurs écrits pour de l’IL plus ancien n’ont tout simplement jamais eu à analyser.

L’ennui est que l’essentiel de ce qui rend un champ ref sûr n’est pas du tout dans de l’IL exécutable. Un champ ref peut pointer vers une variable locale qui sort de portée ; les règles qui l’en empêchent — l’analyse d’échappement de ref — tournent entièrement dans le compilateur et laissent leurs empreintes dans des métadonnées que le JIT ignore : un modreq ici, un ScopedRefAttribute là, un [UnscopedRef] sur la seule méthode autorisée à rompre le défaut. Un décompilateur qui ne lit que les instructions produit du code qui tourne à l’identique et exprime un contrat de sûreté différent — et vous découvrez la différence seulement quand vous le recompilez et que le compilateur autorise un échappement que l’auteur interdisait, ou interdit un renvoi que l’auteur voulait.

Ce billet lit les métadonnées brutes de trois choses — un champ ref, une ref struct qui en stocke un, et un paramètre scoped — et montre où vit la sûreté et comment Glass.NET la retransforme en les bons mots-clés.

champ ref — dans la signatureordinary: FIELD I4ref int: FIELD BYREF I4le BYREF initial est le ref — illégal avant .NET 7scoped — dans un attributparam: BYREF T + [ScopedRef] (no runtime effect)laissez-le tomber → la ref peut échapper : contrat plus faible[UnscopedRef] — la porte de sortiemethod: ref int GetRef() + [UnscopedRef] on the methodlaissez-le tomber → le renvoi ref ne compile plusce qu'émet une lecture byref-seulpublic int field; ← ref perduvoid M(ref T x) ← scoped perdutourne pareil ; le contrat de sûreté ref a changé

Un champ ref est un BYREF dans une signature FIELD

Commencez par la plus petite ref struct qui contienne un champ ref et lisez ce que le compilateur émet.

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

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

La déclaration du champ compile vers un champ dont la signature n’est pas le « FIELD puis type d’élément » habituel. C’est FIELD, puis ELEMENT_TYPE_BYREF, puis le type d’élément :

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

Cet octet 10 — un BYREF à l’intérieur d’une signature de champ — est toute la fonctionnalité au niveau des métadonnées, et c’est exactement ce qu’un runtime antérieur à .NET 7 refusait de charger. Un décompilateur plus ancien qui parcourt une signature de champ en attendant FIELD <type> lit le 10 comme le type d’élément d’une signature malformée, ou le saute, et affiche le champ comme int32 Value tout court. Le code source résultant déclare un champ de valeur, pas un champ ref — il laisse tomber la seule propriété qui rend IntRef digne d’être écrit, et il ne compilera pas à l’intérieur d’une ref struct comme le même type.

L’affectation ref dans le constructeur est sa propre instruction, distincte d’un stockage de valeur :

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

Le stfld cible un champ dont le type est int32&. Comparez à la version valeur, this.Value = target, qui ferait d’abord un ldind.i4 pour déréférencer target puis stfld int32 dans un champ de valeur. Glass lit la signature byref du champ, reconnaît le stfld dans un champ byref comme une affectation-ref, et reconstruit Value = ref target plutôt que Value = target — deux instructions qui diffèrent d’un mot-clé et signifient des choses entièrement différentes quant à la durée de vie.

Les marqueurs de ref struct autour

IntRef lui-même porte les métadonnées qui disent « ceci est une ref struct » : c’est un type valeur marqué de deux attributs, et un décompilateur a besoin des deux pour régénérer le modificateur ref struct et la garde d’obsolescence qui tient les anciens compilateurs à distance.

.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 est le vrai marqueur — c’est ce en quoi ref struct compile, et c’est ce qui dit au compilateur que le type ne peut pas être boxé, ne peut pas être un champ d’une struct non-ref, ne peut pas être capturé, et ainsi de suite. Le [Obsolete(..., error: true)] avec ce message spécifique est une garde que le compilateur émet pour qu’un compilateur trop ancien pour comprendre les types byref-like refuse d’y toucher au lieu de le mal gérer en silence — le même motif d’échec-fermé que vous voyez avec les membres required. Glass mappe IsByRefLikeAttribute vers le ref dans ref struct et reconnaît le [Obsolete] synthétique apparié comme généré par le compilateur, donc il ne fait pas remonter l’attribut comme si l’auteur l’avait écrit — mais il préserve bien le mot-clé ref struct qui l’a produit.

scoped : un contrat sans instructions

Maintenant la partie qu’aucune quantité de lecture d’IL ne récupérera, car il n’y a pas d’IL à lire. scoped contraint où une ref peut voyager, et cette contrainte n’est vérifiée qu’à la compilation.

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 sur le paramètre x compile vers un attribut marqueur sur ce paramètre et ne change pas un octet du corps de la méthode :

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

x comme y sont int32& dans la signature — des paramètres byref identiques pour ce qui concerne les instructions. La seule chose qui distingue scoped ref de ref tout court est le ScopedRefAttribute sur .param [1]. Le JIT l’ignore entièrement ; la méthode tourne pareil avec ou sans. Mais pour le compilateur C# c’est une promesse que x n’échappera pas, ce qui permet aux appelants de passer en sûreté une référence vers une variable locale à courte durée de vie comme x.

Un décompilateur qui liste les paramètres depuis la seule signature voit deux paramètres ref int et émet Risky(ref int x, ref int y). Cela compile, et tourne à l’identique — mais a élargi en silence le contrat : la méthode régénérée permet désormais return ref x, un échappement que l’original interdisait explicitement. Personne ne le remarque jusqu’à ce que quelqu’un édite le code source décompilé, ajoute un return ref x, et que le compilateur qui aurait dû le rejeter l’accepte, car le scoped qui l’aurait arrêté a disparu. Glass lit les attributs personnalisés de .param aux côtés de la signature et restaure scoped exactement sur les paramètres qui portent ScopedRefAttribute.

ref, in et ref readonly partagent un BYREF

La même discipline « lis le modificateur, pas seulement le byref » sépare les trois sortes de byref orientées lecture, indiscernables dans la signature brute.

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)

Les trois paramètres sont int32&. in ajoute modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) à la signature et marque le paramètre [IsReadOnly] ; ref readonly porte le signal lecture seule sans l’indulgence spécifique à in au site d’appel, de sorte que le compilateur peut avertir quand un argument est passé par une forme que in aurait acceptée en silence. Un décompilateur qui ne lit que le BYREF réduit les trois à ref, ce qui change la signature de la surcharge et les règles à chaque site d’appel. Glass inspecte le modreq et l’attribut [IsReadOnly] ensemble et choisit ref, in ou ref readonly en conséquence.

[UnscopedRef] : la porte de sortie qui doit survivre

scoped resserre ; [UnscopedRef] desserre — et le laisser tomber casse la compilation au lieu d’affaiblir un contrat en silence, ce qui en fait le seul de ces éléments qu’un décompilateur ne peut absolument pas se permettre de manquer.

Certaines refs sont implicitement cadrées. Le this d’une méthode d’instance de struct en est une : par défaut vous ne pouvez pas renvoyer une ref vers un champ de la struct, car la struct pourrait être un temporaire. Une struct qui a besoin de remettre une ref vers elle-même marque la méthode [UnscopedRef] :

public struct Buffer
{
    private int _first;

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

Dans les métadonnées la méthode porte 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
}

Si un décompilateur laisse tomber cet attribut, le ref int First() => ref _first; régénéré est rejeté par le compilateur — le cadrage par défaut sur this interdit de renvoyer une ref vers _first. Donc contrairement à scoped, où la perte est un élargissement silencieux, la perte de [UnscopedRef] est une erreur de compilation bruyante à l’instant où vous essayez de reconstruire. Dans les deux cas le code source décompilé ne correspond plus au programme de l’auteur. Glass fait remonter [UnscopedRef] exactement là où il apparaît, car ici l’attribut n’est pas de la décoration — c’est ce qui rend la méthode légale, et le type régénéré ne fait pas l’aller-retour sans lui.

Tout lire ensemble

Le fil conducteur à travers les champs ref, scoped, in/ref readonly et [UnscopedRef] est que le modèle de sûreté ref de C# vit en trois endroits dont le JIT ne se soucie pas : le BYREF dans une signature (la ref elle-même), le modreq à côté (in contre ref tout court), et les attributs autour (ScopedRefAttribute, IsReadOnlyAttribute, UnscopedRefAttribute). Un décompilateur qui ne lit que les instructions exécutables obtient du code qui tourne et un contrat de sûreté subtilement ou complètement erroné — réaffectable là où l’original était à usage unique, échappable là où l’original était cadré, ou non compilable là où l’original allait bien. Glass.NET lit la signature, les modificateurs personnalisés et les attributs de paramètre et de méthode comme une seule unité, ce qu’il faut pour vous rendre ref struct, ref int, scoped ref, ref readonly et [UnscopedRef] tels que l’auteur les a écrits — du code source qui à la fois tourne pareil et porte les mêmes garanties de sûreté ref.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.