Décompiler un initialiseur de tableau : les valeurs vivent dans un blob de données et RuntimeHelpers.InitializeArray est le seul indice
Écrivez int[] x = { 1, 2, 3, 4, 5 } et vous pourriez vous attendre à cinq instructions stelem, une par élément. Pour un petit tableau, c'est à peu près ce que vous obtenez, mais au-delà d'un seuil le compilateur C# cesse de stocker les éléments un par un et fait quelque chose de plus étrange : il alloue le tableau, puis appelle RuntimeHelpers.InitializeArray avec un handle vers un champ statique dont les octets sont le tableau entier empaqueté bout à bout, et ce champ n'a aucun initialiseur en IL — ses données vivent dans une section de données du PE, pointée par une ligne de métadonnées FieldRVA, typée comme une struct synthétique dans une classe cachée dont la seule propriété est sa longueur en octets. Un décompilateur ne peut pas lire le littéral depuis l'IL, parce que l'IL ne contient qu'un memcpy ; il doit reconnaître le motif InitializeArray, résoudre le jeton du champ, suivre le FieldRVA jusqu'aux octets bruts, prendre le type d'élément du newarr qui l'a précédé, et redécouper le blob little-endian en { 1, 2, 3, 4, 5 }. Cet article parcourt l'IL et les métadonnées d'un tableau initialisé par blob, montre ce que Glass.NET reconstruit, explique pourquoi un tableau de chaînes n'emprunte jamais ce chemin, et couvre le cas du ReadOnlySpan d'octets où il n'y a aucun tableau du tout — juste un handle de champ et un CreateSpan.
Écrivez ceci :
int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
et l’intuition naturelle est que le compilateur émet neuf stockages — charger le tableau, charger un index, charger une valeur, stelem.i4, neuf fois de suite. Pour un tableau court, c’est à peu près ce qu’il fait. Mais au-delà d’une poignée d’éléments, le compilateur C# bascule vers quelque chose qui ne ressemble en rien au source : il alloue le tableau puis appelle RuntimeHelpers.InitializeArray, en lui passant un pointeur vers un champ statique dont les octets sont le tableau entier. Les valeurs ne sont plus du tout dans le code. Elles ont été empaquetées dans une section de données de l’assembly, et la seule chose qui reste dans l’IL est une copie en bloc.
C’est un vrai problème pour un décompilateur, parce qu’il n’y a aucun littéral à lire. L’IL contient un memcpy, pas { 2, 3, 5, ... }. Récupérer l’initialiseur signifie reconnaître le motif, poursuivre un jeton de champ hors du flux d’instructions jusque dans les octets bruts du fichier, et réinterpréter ces octets avec un type que l’IL ne mentionne qu’ailleurs. Cet article parcourt cette poursuite pour Glass.NET.
Ce que le compilateur émet réellement
Voici l’IL du tableau ci-dessus. Les valeurs brillent par leur absence :
IL_0000: ldc.i4.s 9
IL_0002: newarr [System.Runtime]System.Int32 // allocate int[9] — element type lives HERE
IL_0007: dup
IL_0008: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=36' '<PrivateImplementationDetails>'::'2BCA...E6'
IL_000d: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_0012: stloc.0
Quatre éléments portent l’initialiseur entier, et aucun d’eux ne suffit seul :
newarr [System.Runtime]System.Int32— le seul endroit où le type d’élément apparaît. Le blob qui suit est non typé ; cette instruction est ce qui dit que les octets sont desInt32.ldc.i4.s 9— le nombre d’éléments, que le décompilateur peut recouper avec la longueur du blob.ldtoken ... '__StaticArrayInitTypeSize=36'— un handle vers un champ statique synthétique. Le nom encode la longueur en octets : 9 ints x 4 octets = 36. Le second nom (2BCA...E6) est un hash de contenu, si bien que deux initialiseurs identiques n’importe où dans l’assembly partagent un seul champ.call RuntimeHelpers::InitializeArray— le signal. Cet appel précis, prenant unArrayet unRuntimeFieldHandle, est le motif qu’un décompilateur guette.
Les trois sauts jusqu’aux octets
Le champ nommé par ce ldtoken n’a pas d’initialiseur managé. C’est un champ statique d’un type valeur synthétique — __StaticArrayInitTypeSize=36, une struct dont le seul attribut significatif est [StructLayout(Size = 36)] — vivant dans le type caché <PrivateImplementationDetails> que le compilateur crée précisément à cette fin. Ce qui donne au champ sa valeur est une ligne de la table de métadonnées FieldRVA, qui mappe le jeton du champ sur une adresse virtuelle relative à l’intérieur du PE. À cette adresse se trouvent les 36 octets :
02 00 00 00 03 00 00 00 05 00 00 00 07 00 00 00 0B 00 00 00
0D 00 00 00 11 00 00 00 13 00 00 00 17 00 00 00
Chaque groupe de quatre est un Int32, little-endian : 02 00 00 00 vaut 2, 0B 00 00 00 vaut 11, 17 00 00 00 vaut 23. Pour récupérer le littéral, un décompilateur fait donc trois sauts — jeton vers champ, champ vers ligne FieldRVA, FieldRVA vers octets bruts — puis découpe les octets en utilisant le type qu’il a lu sur le newarr.
Ce que Glass reconstruit
Glass.NET reconnaît l’appel InitializeArray, résout le RuntimeFieldHandle vers le champ <PrivateImplementationDetails>, lit ses octets FieldRVA, prend Int32 et sa largeur de 4 octets sur le newarr, et parcourt les 36 octets par quadruplets little-endian. Il rend exactement ce qui a été écrit :
int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
La taille d’élément n’est pas un détail — c’est ce qui fait que les nombres sortent justes. Le même blob de 16 octets 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 vaut { 1, 2, 3, 4 } quand le newarr dit Int32, mais { 1, 2 } quand il dit Int64, parce qu’alors chaque élément fait huit octets lus en little-endian. Glass prend la largeur sur l’allocation, pas sur une supposition, si bien qu’un long[], un double[] (où les huit octets sont un motif binaire IEEE-754) et un char[] (deux octets d’UTF-16 par élément) se découpent chacun correctement. Les blobs partagés sont gérés aussi : parce que le champ est indexé par un hash de contenu, deux initialiseurs identiques dans des méthodes différentes pointent vers le même champ, et Glass rend chaque site d’appel comme son propre littéral complet plutôt que de laisser le partage transparaître dans la sortie.
Quand la forme blob n’apparaît pas
Deux cas restent élément par élément, et un décompilateur les lit directement sur l’IL sans saut FieldRVA :
string[] names = { "ada", "alan", "grace" }; // references — cannot be memcpy'd
int[] sized = new int[Count]; // not constant — no blob to copy
Un string[] contient des références d’objets, pas des caractères ; les pointeurs n’existent pas avant l’exécution et ne sont pas des constantes, si bien qu’il n’y a rien à empaqueter. Le compilateur émet dup / ldc.i4 / ldstr / stelem.ref par élément, que Glass relit directement comme les valeurs de chaîne. Il en va de même pour tout tableau de type référence, et pour tout tableau dont les éléments ne sont pas des constantes de compilation — il n’y a pas de blob constant, donc les stockages restent explicites. C’est pourquoi la forme blob est spécifiquement une optimisation constante-primitive-blittable : c’est le seul cas où la valeur du tableau est ses octets.
Le cas du span : aucun tableau du tout
C# a une astuce de plus qui vaut la peine d’être connue, parce que son IL ressemble presque à la forme tableau mais n’alloue rien :
ReadOnlySpan<byte> magic = [ 0x4D, 0x5A, 0x90, 0x00 ]; // PE header bytes, as a span
Un ReadOnlySpan<byte> construit à partir d’une séquence d’octets constante ne crée pas de tableau sur le tas. Le compilateur pointe le span directement vers les données FieldRVA :
IL_0000: ldsflda field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4' ...
IL_0005: ldc.i4.4
IL_0006: call valuetype [System.Runtime]System.ReadOnlySpan`1<uint8> System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<uint8>(valuetype [System.Runtime]System.RuntimeFieldHandle)
Il n’y a ni newarr ni InitializeArray — à la place, RuntimeHelpers.CreateSpan<byte> enveloppe un span au-dessus des octets du champ sans aucune copie, ce qui explique pourquoi ce motif ne fonctionne que pour byte (et les autres types d’élément d’un seul octet ou indépendants du boutisme, dont la disposition en mémoire est portable). Glass reconnaît CreateSpan de la même façon qu’il reconnaît InitializeArray : résoudre le champ, lire les octets FieldRVA, et rendre l’expression de collection [ 0x4D, 0x5A, 0x90, 0x00 ]. Le signe qui le distingue de la forme tableau est ldsflda + CreateSpan au lieu de newarr + InitializeArray — même section de données, autre porte d’entrée, et aucune allocation derrière.
À retenir
Un initialiseur de tableau de constantes blittables ne survit pas dans l’IL sous forme de liste de valeurs. Le compilateur alloue le tableau, empaquette les éléments en octets bruts little-endian dans un champ statique de <PrivateImplementationDetails>, donne à ce champ ses données via une ligne de métadonnées FieldRVA plutôt que par une instruction, et laisse derrière lui un seul appel RuntimeHelpers.InitializeArray qui memcpy les octets dedans. Un décompilateur ne peut donc pas lire le littéral depuis le code — il doit reconnaître l’appel, résoudre le jeton du champ, suivre le FieldRVA dans la section de données, prendre le type d’élément sur le newarr qui a alloué le tableau, et découper le blob par tranches little-endian de la taille d’un élément. Glass.NET fait exactement cela, et obtient la bonne largeur pour qu’un long[] ne soit pas lu comme deux fois plus d’int ; il lit les cas chaîne et non constant directement sur leurs stockages explicites ; et il reconnaît la variante CreateSpan où un ReadOnlySpan<byte> pointe vers les mêmes données sans aucun tableau. Les valeurs ont toujours été là — simplement à trois sauts du flux d’instructions, dans une section que l’IL ne fait jamais qu’évoquer.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.