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

Décompiler fixed : locales épinglées, la garde null-ou-vide sur les tableaux, et GetPinnableReference

Il n'y a pas d'instruction fixed en IL. Ce que le compilateur C# émet à la place est une variable locale avec le modificateur pinned dans sa signature, quelques instructions qui calculent un pointeur à partir de ce que vous avez épinglé — avec des gardes que vous n'avez jamais écrites — et un store qui vide la locale à la fin du bloc. Il n'y a pas de try/finally, parce qu'un épinglage est une propriété de la durée de vie d'une locale plutôt qu'une région de code. L'initialiseur est abaissé de quatre façons différentes selon ce que vous avez épinglé : un tableau reçoit un test null-ou-vide et un ldelema de l'élément zéro, une chaîne reçoit soit l'arithmétique héritée OffsetToStringData soit un appel à GetPinnableReference, un Span ou tout type doté d'une méthode GetPinnableReference reçoit cet appel sur son adresse, et &field ou &local reçoit un simple ldflda ou ldloca. Cet article lit l'IL réel de chaque forme, explique pourquoi un tableau vide donne un pointeur null plutôt qu'une exception, et montre comment Glass.NET s'ancre sur la locale épinglée pour replier la machinerie en la ligne fixed que vous avez écrite.

fixed est le plus petit morceau de syntaxe de la boîte à outils unsafe de C# et l’une des choses les plus intéressantes à trouver dans l’IL, parce que presque rien n’en survit. Il n’y a pas d’opcode fixed. Il n’y a pas d’instruction pinned. Il n’y a pas de try/finally — contrairement à using et foreach, qui en génèrent un pour leur nettoyage, un bloc fixed n’a aucun appel de nettoyage à faire. Ce qu’il y a à la place, c’est une variable locale avec un modificateur de type inhabituel, quelques instructions qui calculent un pointeur, et un store qui vide la locale quand vous avez terminé.

Et l’initialiseur — ce qui suit le = — est abaissé de quatre façons différentes selon ce que vous avez épinglé. Un décompilateur doit reconnaître chacune pour imprimer la ligne unique que vous avez écrite. Cet article lit l’IL des quatre et montre comment Glass.NET revient à fixed.

L’ancre : une locale avec le modificateur pinned

Tout tient à un seul élément de métadonnées. Prenez le cas le plus simple :

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

La signature des variables locales de la méthode — le blob LocalVarSig vers lequel pointe l’en-tête de méthode — déclare deux locales :

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

La locale 1 est une référence managée vers un byte (uint8&) portant la contrainte pinned. pinned est un modificateur de type d’élément dans l’encodage de la signature (ELEMENT_TYPE_PINNED, 0x45), et il signifie exactement une chose pour le runtime : tant que cette locale contient une référence non nulle, le ramasse-miettes ne peut pas déplacer l’objet vers lequel elle pointe. C’est tout le mécanisme. Il n’y a pas d’instruction qui épingle ; c’est la locale qui épingle, aussi longtemps qu’elle est vivante et non nulle.

C’est aussi pourquoi fixed est récupérable sans PDB. Le modificateur pinned fait partie des métadonnées de signature de la méthode, dont le runtime a besoin et qui sont donc livrées dans chaque assembly, symboles de débogage ou non. Un décompilateur qui trouve une locale épinglée sait qu’un fixed était là ; le reste consiste à déterminer ce qui a été épinglé et pour combien de temps.

Forme 1 : un tableau, et la garde que vous n’avez jamais écrite

Voici le corps de First, et il est plus long que la source ne le laisserait penser :

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

Deux choses là-dedans ne sont pas dans votre source.

La première est la garde null-ou-vide. Vous avez écrit fixed (byte* p = data) et le compilateur a émis un test de nullité et un test de longueur avant d’épingler quoi que ce soit. La raison est la règle du langage pour les tableaux : prendre &data[0] d’un tableau vide lèverait IndexOutOfRangeException, et la spécification définit à la place qu’un fixed sur un tableau null ou de longueur zéro produit un pointeur null. Le code abaissé est donc à trois voies — tableau null, tableau vide, vrai tableau — et seul le troisième chemin exécute réellement ldelema et stocke dans la locale épinglée. Les deux autres stockent un pointeur null dans p et n’épinglent jamais rien. Cela vaut la peine d’être su quand on lit du code unsafe : *p sur le chemin du tableau vide déréférence null, et la faute se produit à l’utilisation, pas au fixed.

La seconde est le store de vidage en IL_001d–IL_001f. Le compilateur stocke une référence nulle dans la locale épinglée à la fin normale du bloc. Ce n’est pas une exigence de correction — l’épinglage se terminerait de toute façon quand la locale mourrait ou que la frame serait dépilée — c’est le compilateur qui termine l’épinglage rapidement, pour que le GC puisse compacter le tableau dès que vous avez terminé plutôt qu’à la sortie de la méthode. Dans les builds optimisés où le bloc est immédiatement suivi d’un ret, le compilateur l’omet parfois, et c’est pourquoi un décompilateur doit traiter le store de vidage comme optionnel : son absence signifie « la méthode s’est terminée », pas « ce n’était pas un fixed ».

Un épinglage est une durée de vie, pas une régionif (data == null || data.Length == 0) p = null; // garde ajoutée par le compilateurelse { pinned = ref data[0]; p = (byte*)pinned; } // l'épinglage COMMENCE à ce storetant que `pinned` contient une référence, le GC ne peut pas déplacer `data`... corps : *p, p[i], passer p à du code natif ...pas de .try, pas de gestionnaire — rien n'a à S'EXÉCUTER quand le bloc se terminepinned = null; // l'épinglage SE TERMINE (optionnel, rapide)sur une exception l'épinglage se termine quand la frame est dépilée ; sur `ret` la locale meurtUn décompilateur qui cherche un finally ici n'en trouvera pas — l'ancre est la durée de vie de la locale épinglée.

Forme 2 : une chaîne, en deux générations

Les chaînes sont épinglables depuis C# 1, et l’abaissement a changé en chemin, un décompilateur rencontre donc deux formes pour la même ligne source :

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

La forme héritée — ce que vous obtenez en ciblant .NET Framework, et ce que les anciennes versions de Roslyn émettaient partout — épingle l’objet chaîne lui-même et fait de l’arithmétique de pointeurs pour atteindre ses caractères :

.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

La locale épinglée est ici de type string — une référence d’objet épinglée plutôt qu’un pointeur managé épinglé — et RuntimeHelpers.OffsetToStringData est la réponse du runtime à « combien d’octets entre le début d’un objet String et son premier char ». Le compilateur ajoute cela à l’adresse de l’objet pour atterrir sur les caractères. La garde de nullité est là parce que OffsetToStringData ajouté à une adresse nulle produirait un pointeur non nul bidon ; fixed sur une chaîne nulle doit donner null.

La forme moderne — .NET Core 3.0 et ultérieur, où string expose GetPinnableReference() — abandonne entièrement l’arithmétique :

.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

Désormais la locale épinglée est un char& — un pointeur managé directement sur le premier caractère — et la méthode qui l’a produit est string.GetPinnableReference(), dont le type de retour est ref readonly char (le modreq(InAttribute) sur la signature IL est la façon dont ref readonly s’écrit dans les métadonnées). Pas de décalage, pas d’arithmétique. La même ligne source, deux séquences d’instructions complètement différentes, que le décompilateur doit toutes deux ramener à fixed (char* p = s). (La disposition exacte des branches varie un peu entre les versions de Roslyn ; les empreintes — la locale épinglée, la garde de nullité, l’appel à GetPinnableReference ou l’addition OffsetToStringData — ne varient pas.)

Forme 3 : les spans et le motif GetPinnableReference

C# 7.3 a généralisé la forme moderne des chaînes en un motif : tout type doté d’une méthode accessible GetPinnableReference() renvoyant ref T ou ref readonly T peut être l’opérande de fixed, et Span<T>, ReadOnlySpan<T> et les types proches de Memory<T> en ont tous une.

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

Deux différences avec la version chaîne ressortent. Le récepteur est un type valeur, l’appel se fait donc sur ldarga.s values — l’adresse du span — avec un simple call, et il n’y a pas de garde de nullité : un struct ne peut pas être null, et Span<T>.GetPinnableReference() est spécifié pour renvoyer lui-même une référence nulle pour un span vide (donc p ressort null sur une entrée vide, conformément à la règle des tableaux, mais par le contrat de l’appelé plutôt que par la garde de l’appelant). Le travail du décompilateur est donc légèrement différent selon la forme : pour un tableau, il doit reconnaître et absorber la garde ; pour un span, il doit reconnaître que l’absence de garde est normale.

Le motif s’applique aussi à vos propres types. Donnez à un struct un public ref byte GetPinnableReference() — ou fournissez-en un comme méthode d’extension — et fixed (byte* p = myBuffer) compile vers le même triplet call/stloc pinned/conv.u. Un décompilateur qui lit un appel à une méthode nommée GetPinnableReference dont le résultat est stocké dans une locale épinglée peut le replier quel que soit le type déclarant, ce qui est exactement la règle par duck typing qu’a utilisée le compilateur.

Forme 4 : une adresse explicite

La dernière forme est celle avec le moins d’abaissement, parce que vous avez fourni l’adresse vous-même :

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 et ldflda produisent directement la référence managée ; elle va dans la locale épinglée, puis conv.u dans le pointeur. Pas de garde — l’indexeur lève de lui-même si 3 est hors limites, et un accès à un champ sur un objet null lève de lui-même — le compilateur n’ajoute donc rien. Un décompilateur reconnaît cette forme par élimination : une locale épinglée initialisée à partir d’une instruction produisant une adresse qui n’est ni l’élément zéro derrière une garde ni un appel à GetPinnableReference est un &expr explicite, et l’expression est ce vers quoi le calcul d’adresse se décompile.

Les tampons de taille fixe — fixed int Values[8]; à l’intérieur d’un struct — sont une fonctionnalité différente qui se trouve partager le mot-clé ; ils produisent un type de tampon imbriqué et un ldflda sur celui-ci, et sur les cibles modernes ils sont supplantés par les inline arrays. Décompiler les inline arrays et stackalloc couvre ce versant.

Comment Glass.NET le replie

En rassemblant les quatre formes, la récupération de fixed dans Glass.NET est ancrée sur la seule chose qu’elles partagent toutes, puis dispatchée sur l’initialiseur.

OpérandeEmpreinte IL (dans une locale épinglée)Glass.NET imprimetableaugarde brfalse / ldlen brtrue,ldc.i4.0 ldelema T → stloc T& pinnedfixed (T* p = array)chaîne (héritée)stloc string pinned, conv.u,garde brfalse, addition OffsetToStringDatafixed (char* p = s)chaîne / span / personnalisé[garde null si type référence]call GetPinnableReference → stloc T& pinnedfixed (T* p = operand)&exprldflda / ldloca / ldelema (index ≠ 0 ousans garde) → stloc T& pinnedfixed (T* p = &expr)

Les étapes, dans l’ordre :

  1. Trouver les locales épinglées. Elles sont déclarées dans la signature des locales, c’est donc une lecture de métadonnées, pas une inférence. Chaque locale épinglée est un fixed candidat.
  2. Délimiter la région. Elle commence au premier store d’une valeur non nulle dans la locale épinglée et se termine au store qui la vide — ldc.i4.0; conv.u; stloc pour une référence épinglée, ldnull; stloc pour un objet épinglé — ou, quand aucun store de vidage n’existe, à la sortie de la méthode. Roslyn réutilise les slots épinglés pour des instructions fixed séquentielles dans la même méthode, une seule locale épinglée peut donc délimiter plusieurs régions ; chaque store de vidage en ferme une et le store non nul suivant en ouvre une autre.
  3. Classifier l’initialiseur d’après l’instruction qui a produit la valeur stockée dans la locale épinglée : ldelema avec l’index constant 0 derrière une garde null/longueur est un tableau ; un appel à GetPinnableReference (quel que soit le type déclarant) est la forme moderne chaîne/span/personnalisée, avec la garde absorbée quand le récepteur est un type référence ; l’arithmétique OffsetToStringData sur une string épinglée est la forme héritée des chaînes ; tout le reste qui produit une référence managée est un &expr, décompilé comme l’expression qu’il est.
  4. Identifier la variable du fixed. C’est la locale pointeur non managé dans laquelle le conv.u de la locale épinglée est stocké. Son type pointeur déclaré (uint8*, char*, int32*) devient le type dans fixed (T* p = …), et ses utilisations dans la région sont des utilisations de p.
  5. Replier — supprimer la garde, le temporaire, la locale épinglée, le conv.u et le store de vidage ; émettre fixed (T* p = operand) { region }.

Quand une forme ne s’aligne pas — la locale épinglée est lue pour sa propre valeur dans la région, la garde teste autre chose que l’opérande épinglé, une locale pointeur est affectée depuis deux locales épinglées différentes — Glass.NET refuse de replier et montre la forme abaissée à la place, avec la locale épinglée annotée // pinned. Il n’existe pas de syntaxe C# pour une locale épinglée, l’annotation est donc le rendu honnête ; un fixed qui déformerait ce qui est épinglé, ou pour combien de temps, serait pire qu’un fixed non replié.

Pourquoi cela compte quand vous lisez le code de quelqu’un d’autre

fixed apparaît précisément dans le code que vous voulez le plus lire attentivement : marshalling P/Invoke, parseurs faits main sur Span<byte>, codecs image et audio, tout ce qui passe un pointeur à du code natif. Trois détails de l’abaissement sont ce que vous vérifiez réellement quand vous le relisez. Le pointeur null du tableau vide est une classe de bug classique — le pointeur est null et le premier déréférencement fait faute, loin du fixed. La durée de vie de l’épinglage est le second : l’épinglage se termine quand la locale est vidée ou que la frame est dépilée, de sorte qu’un pointeur qui s’échappe du bloc — stocké dans un champ, passé à un callback natif qui se déclenche plus tard — pointe vers une mémoire que le GC est libre de déplacer dès que la méthode retourne. Et l’absence de garde sur la forme span signifie que c’est le contrat de l’appelé, pas votre code, qui garde le cas vide sûr.

Un décompilateur qui imprime fixed (byte* p = data) vous a montré l’intention. Un décompilateur qui peut aussi vous montrer la forme abaissée — la garde, la locale épinglée, le store de vidage — vous a montré ce que le runtime fera réellement, ce qui, pour du code unsafe, est ce qui compte.

Essayez Nebula.NET

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