Décompiler les inline arrays et stackalloc de C# 12 : des tampons de taille fixe sans le unsafe
Les inline arrays de C# 12 vous donnent un tampon de taille fixe, de type valeur, qui vit en ligne dans sa structure conteneur — ce que le C appelait `int buf[8]` — mais en IL c'est une structure à un seul champ décorée de [InlineArray(8)], accédée via des intrinsèques du runtime et Span<T> plutôt que par une quelconque instruction de tableau. stackalloc, quant à lui, compile en un localloc brut qu'un décompilateur naïf montre comme un pointeur et un tas d'opcodes stind. Trompez-vous sur l'un ou l'autre et le décompilateur émet de l'arithmétique de pointeurs unsafe là où la source avait un indexeur propre, ou invente un tableau jamais alloué. Voici exactement ce que le compilateur émet pour un type [InlineArray], comment l'accès aux éléments s'abaisse en un helper de PrivateImplementationDetails sur un Span, à quoi ressemblent localloc et le constructeur Span(void*, int) en IL, et comment Glass.NET les relit vers les déclarations d'inline array et les expressions stackalloc que vous avez réellement écrites.
C# 12 a ajouté les inline arrays, et ils sont faciles à mal lire même en source : un tampon de taille fixe qui vit à l”intérieur de sa structure conteneur, de type valeur jusqu”au bout, sans allocation sur le tas, sans objet tableau. C”est la réponse managée et sûre au int buf[8] du programmeur C : ce que vous ne pouviez auparavant obtenir qu”avec un tampon de taille fixe unsafe. Associé à stackalloc, qui a été discrètement modernisé pour vous remettre un Span<T> au lieu d”un pointeur brut, vous pouvez désormais écrire du code intensif en tampons et sans allocation sans un seul mot-clé unsafe.
Cette sûreté est une illusion au niveau de la source, construite par le compilateur, et l”illusion est exactement ce qu”un décompilateur doit reconstruire. Dessous, un inline array n”est pas un tableau : c”est une structure à un seul champ avec un attribut qui dit au runtime de disposer N copies du champ. L”accès aux éléments n”est pas ldelem : c”est un appel à un intrinsèque du runtime qui renvoie une référence managée. Et stackalloc n”est pas du tout sûr dessous : c”est un localloc, l”allocation de pile la plus brute dont dispose le CLR, enveloppée dans un constructeur de Span pour que le pointeur n”atteigne jamais vos yeux. Un décompilateur qui descend au métal et s”y arrête vous montre de l”arithmétique de ref, des appels à des helpers aux noms que vous ne pouvez pas taper, et des blocs unsafe qui n”étaient jamais dans la source. Le code qu”il émet souvent ne recompile même pas.
Ce billet parcourt l”IL des deux fonctionnalités et montre ce qu”un décompilateur doit reconnaître pour reconstituer la source.
Un inline array est une structure qui porte un attribut
Voici la déclaration et une utilisation triviale :
[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer8
{
private int _element0;
}
public static int Sum(Buffer8 buf)
{
int total = 0;
for (int i = 0; i < 8; i++)
total += buf[i];
return total;
}
Buffer8 a un champ, _element0, et un attribut, [InlineArray(8)]. C”est toute l”astuce : l”attribut ordonne au runtime d”allouer le champ huit fois de façon contiguë, donc un Buffer8 fait 32 octets et buf[3] signifie « le quatrième int de ce bloc ». Il n”y a pas d”objet tableau ni de mot de longueur : la longueur est cuite dans l”attribut, connue à la compilation, et jamais stockée à l”exécution.
En métadonnées, le type est un sealed value type lisse avec un unique champ. Rien dans la définition du type ne dit « tableau » ; le seul signal est l”attribut personnalisé :
.class public sequential ansi sealed beforefieldinit Buffer8
extends [System.Runtime]System.ValueType
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.InlineArrayAttribute::.ctor(int32)
= ( 01 00 08 00 00 00 00 00 ) // InlineArray(8)
.field private int32 _element0
}
Un décompilateur qui classe les types par leur forme voit une structure à un champ et, à moins de lire cet attribut, l”imprime comme une structure à un champ, perdant tout le sens. La première règle de reconnaissance est donc : un type valeur portant [InlineArray(N)] est un inline array du type de son unique champ, de longueur N, et il doit être rendu avec l”attribut et l”unique champ de stockage exactement comme le compilateur les a émis.
L”indexation s”abaisse en un intrinsèque du runtime, pas en ldelem
Maintenant l”accès. buf[i] compile non pas en un chargement de tableau, mais en un appel qui produit une référence managée à l”élément i. Le compilateur l”achemine via un helper généré dans <PrivateImplementationDetails> qui enveloppe RuntimeHelpers.InlineArrayElementRef (ou construit un Span<T> sur le tampon avec InlineArrayAsSpan et l”indexe, selon le contexte). La lecture dans la boucle s”abaisse à peu près à :
// total += buf[i];
ldarga.s buf
ldloc i
call !!1& [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InlineArrayElementRef<valuetype Buffer8, int32>(!!0&, int32)
ldind.i4
add
L”appel à InlineArrayElementRef prend une référence au tampon et un index, et renvoie un byref à l”élément ; ldind.i4 le déréférence ensuite pour charger l”int. Pour une écriture, vous verriez stind.i4 contre le même genre de ref. Pas de ldelem, pas d”opcode de vérification de bornes, rien qui ressemble à un tableau : juste un appel intrinsèque qui renvoie un ref.
Un décompilateur naïf le rend littéralement : un appel à RuntimeHelpers.InlineArrayElementRef<Buffer8, int>(ref buf, i) suivi d”une déréférence, ou pire, une référence à un helper de <PrivateImplementationDetails> dont le nom contient des caractères que vous ne pouvez pas écrire en C#. Dans les deux cas, la sortie ne dit pas buf[i] et ne recompile pas. La règle de reconnaissance est : un appel à l”intrinsèque de ref d”élément d”inline array (ou au helper généré autour de lui) contre un type [InlineArray], suivi d”un chargement ou d”un stockage via la ref renvoyée, est un accès d”indexeur : rendez-le comme buf[i]. Il en va de même pour la forme InlineArrayAsSpan, qu”un décompilateur devrait replier soit en un accès d”élément, soit en une vue Span<T> du tampon, selon l”usage du résultat.
stackalloc est un localloc qui porte un Span
Maintenant la seconde fonctionnalité. Un stackalloc moderne assigné à un Span<T> paraît complètement sûr en source :
public static int SumFirstFour(ReadOnlySpan<int> input)
{
Span<int> scratch = stackalloc int[4];
for (int i = 0; i < 4; i++)
scratch[i] = input[i] * input[i];
int total = 0;
foreach (int v in scratch) total += v;
return total;
}
Pas de unsafe, pas de pointeur. Mais stackalloc a exactement un abaissement : l”instruction localloc, qui alloue un bloc sur le cadre de pile de la méthode courante et empile un pointeur natif vers lui. Le compilateur dimensionne le bloc (4 * sizeof(int)), alloue, puis construit un Span<int> sur le pointeur brut et le nombre d”éléments à l”aide du constructeur Span<T>(void*, int) :
// Span<int> scratch = stackalloc int[4];
ldc.i4.4
conv.u
ldc.i4.4
mul.ovf.un // 4 elements * 4 bytes
localloc // -> native int (pointer to the stack block)
ldc.i4.4
newobj instance void valuetype [System.Runtime]System.Span`1<int32>::.ctor(void*, int32)
stloc.0 // scratch
Voilà la signature : un ldc du nombre d”éléments, une multiplication de taille (conv.u / mul.ovf.un), un localloc, puis un newobj sur Span<T>::.ctor(void*, int32). Un décompilateur qui rend ces instructions littéralement produit une méthode unsafe avec un void*, une multiplication, un intrinsèque localloc (que C# ne peut même pas exprimer directement), et un constructeur de Span prenant un pointeur. Rien de cela n”est dans la source, et le void* force un contexte unsafe que l”auteur a délibérément évité.
La règle de reconnaissance replie toute la forme en une expression : un localloc dont la taille est count * sizeof(T), consommé par le constructeur (void*, int) de Span<T> ou ReadOnlySpan<T>, est stackalloc T[count]. Quand la taille du type d”élément est une constante de compilation et le nombre aussi, Glass reconstruit stackalloc int[4] ; quand le nombre est une variable, il reconstruit stackalloc int[n]. Le pointeur brut, la multiplication et le constructeur disparaissent tous vers l”unique stackalloc que l”auteur a tapé, et la méthode reste sûre : pas de mot-clé unsafe, car la source n”en avait aucun.
Il y a une subtilité qui mérite d”être nommée : tout stackalloc ne devient pas un Span. La forme plus ancienne, int* p = stackalloc int[4];, assigne le pointeur directement et est véritablement unsafe : là, le void* et le unsafe appartiennent à la sortie, car ils étaient dans la source. Le décompilateur distingue les deux par ce qui consomme le localloc : un constructeur de Span/ReadOnlySpan signifie la forme sûre, une assignation directe de pointeur signifie la forme unsafe. Réussir cette distinction est la différence entre reproduire fidèlement une méthode sûre et l”accuser à tort d”être unsafe.
Pourquoi la récupération fidèle importe ici en particulier
Pour la plupart des fonctionnalités du langage, un décompilateur qui se trompe légèrement sur les détails produit du code laid mais qui compile quand même. Ces deux-là sont différentes, car elles existent précisément pour vous donner la performance de bas niveau tout en restant en C# sûr et de haut niveau, et la décompilation incorrecte détruit exactement cette propriété.
Un inline array rendu comme des appels à InlineArrayElementRef référence des helpers dans <PrivateImplementationDetails>, un type interne du compilateur dont les membres ont des noms contenant < et > illégaux en source C#. Cette sortie ne peut pas être recollée et compilée ; c”est une description de l”IL, pas une reconstruction du programme. Un stackalloc rendu comme localloc plus un void* transforme une méthode sûre en une méthode qui requiert unsafe, dénaturant le contrat de sûreté de la méthode et, encore une fois, échouant souvent à compiler sans modifications que l”auteur n”a jamais faites.
Glass lit les deux motifs au niveau auquel l”auteur a travaillé. Il classe les structures [InlineArray(N)] comme inline arrays et rend leur accès aux éléments comme des indexeurs ; il reconnaît la forme localloc-plus-constructeur-de-Span comme stackalloc et garde la méthode sûre, tout en distinguant la forme de pointeur véritablement unsafe. La sortie se lit comme la source — buf[i] et stackalloc int[4] — et, tout aussi important, recompile comme la source, ce qui est le vrai test pour savoir si un décompilateur a compris le programme ou s”est contenté d”en transcrire les octets.
Résumé
Les inline arrays et le stackalloc moderne sont deux des cas les plus clairs où l”IL est dramatiquement de plus bas niveau que le C#. Un inline array est une structure à un champ dont l”attribut [InlineArray(N)] est la seule preuve que c”est un tampon, indexé via des intrinsèques du runtime qui renvoient des byrefs plutôt qu”un quelconque opcode de tableau. Un stackalloc de type Span est un localloc brut habillé d”un constructeur Span<T>(void*, int) pour que le pointeur ne se montre jamais. Un décompilateur mérite son salaire en récupérant l”intention de l”auteur : la déclaration de l”inline array et ses indexeurs, l”unique expression stackalloc, et le corps de méthode sûr et sans unsafe qui a rendu ces fonctionnalités dignes d”être utilisées au départ.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.