Décompiler la disposition explicite des structs et les unions à la C : reconstruire [StructLayout] et [FieldOffset] depuis les métadonnées
La disposition explicite des structs est l'une des rares fonctionnalités C# qu'un décompilateur ne peut pas récupérer à partir des formes du code, car elle ne laisse aucun code : elle vit entièrement dans les métadonnées. Le LayoutKind (Sequential/Explicit/Auto) est un flag sur la définition du type, Pack et Size se trouvent dans la table ClassLayout, et le décalage en octets de chaque champ se trouve dans la table FieldLayout. Lorsque deux champs partagent un décalage, vous avez une union à la C : des octets aliasés, un int qui voit les mêmes quatre octets qu'un float. Trompez-vous sur un seul décalage et le struct décompilé corrompt silencieusement chaque appel d'interop et de marshalling qui le touche. Cet article montre où vivent les données de disposition, comment Glass.NET lit les tables ClassLayout et FieldLayout et réémet [StructLayout(LayoutKind.Explicit, Pack = ..., Size = ...)] sur le type et [FieldOffset(n)] sur chaque champ, comment il reconnaît une union à partir de décalages qui se chevauchent, et pourquoi préserver les décalages exacts n'est pas négociable.
La plupart des fonctionnalités du langage C# qu”un décompilateur récupère sont du sucre du compilateur : le compilateur a abaissé un foreach en un try/finally autour d”un énumérateur, ou un lock en Monitor.Enter/Exit, et le décompilateur reconnaît la forme du code émis et le replie. La disposition explicite des structs n”est pas de cette nature. Il n”y a aucun code à reconnaître, car la disposition n”est pas exécutable. Un struct [StructLayout(LayoutKind.Explicit)] avec [FieldOffset] sur chaque champ compile vers des métadonnées — des lignes dans deux tables annexes et quelques bits de flag — et rien dans aucun corps de méthode ne le mentionne. Un décompilateur qui ne reconstruit que de l”IL en instructions émettra un struct parfaitement plausible dont la disposition est complètement fausse.
Cela compte ici plus que presque partout ailleurs, car la disposition explicite existe pour honorer un contrat mémoire dont dépend quelque chose hors de votre assembly : un struct natif marshallé via P/Invoke, un bloc de registres de périphérique, un en-tête de paquet, une superposition d”union étiquetée. Les décalages sont l”interface. Cet article parcourt exactement où vit la disposition dans les métadonnées, comment Glass la relit, et pourquoi un seul décalage perdu est l”une des erreurs les plus dangereuses qu”un décompilateur puisse commettre.
Un struct union dans le source
Voici le cas canonique : une valeur qui peut être vue comme un int, un float ou quatre octets individuels, partageant tous le même stockage :
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
[FieldOffset(0)] public int AsInt;
[FieldOffset(0)] public float AsFloat;
[FieldOffset(0)] public byte Byte0;
[FieldOffset(1)] public byte Byte1;
[FieldOffset(2)] public byte Byte2;
[FieldOffset(3)] public byte Byte3;
}
AsInt et AsFloat commencent tous deux à l”octet 0. Écrire AsInt puis lire AsFloat réinterprète les mêmes quatre octets — un cast de bits d”int vers float sans conversion —, exactement l”astuce à laquelle les programmeurs C recourent avec une union. Les quatre champs byte superposent le même mot pour que vous puissiez le découper octet par octet. Il n”y a pas de mot-clé union en C# ; les attributs [FieldOffset] qui se chevauchent sont l”union.
La disposition est tout l”enjeu. Si les champs étaient disposés bout à bout, Register32 ferait sept octets de large et la réinterprétation serait cassée. Le chevauchement est délibéré, et il n”est enregistré dans aucune méthode — seulement dans les métadonnées.
Où vit réellement la disposition
Trois éléments indépendants de métadonnées la portent, et un décompilateur doit lire les trois.
Le type de disposition est un flag sur le type. Dans la table TypeDef, la colonne Flags (TypeAttributes) contient un LayoutMask de deux bits :
0x00000000—AutoLayout(le runtime peut réordonner les champs)0x00000008—SequentialLayout(le chargeur empaquette dans l”ordre de déclaration)0x00000010—ExplicitLayout(chaque décalage est spécifié à la main)
Aucun opcode IL ne définit cela. C”est une directive du chargeur, lue au chargement du type, invisible pour tout ce qui ne regarde que les corps des méthodes.
Pack et Size vivent dans la table ClassLayout. Chaque ligne lie un type à un PackingSize (1, 2, 4, 8, 16, ou 0 pour « par défaut ») et à un ClassSize (le Size total explicite, ou 0 pour laisser les champs décider). Un type n”obtient une ligne ClassLayout que lorsqu”il a besoin d”un pack non par défaut ou d”une taille explicite.
Le décalage de chaque champ vit dans la table FieldLayout. Pour un type à disposition explicite, chaque champ a une ligne FieldLayout qui apparie le champ à son Offset en octets. Ce sont les données du chevauchement : deux lignes avec le même décalage signifient deux champs aliasés.
Rendu tel qu”ILDasm l”affiche, Register32 a cette allure — notez explicit dans l”en-tête de la classe, les directives .size/.pack et le décalage entre crochets sur chaque champ :
.class public explicit ansi sealed beforefieldinit Register32
extends [System.Runtime]System.ValueType
{
.pack 0
.size 4
.field [0] public int32 AsInt
.field [0] public float32 AsFloat
.field [0] public uint8 Byte0
.field [1] public uint8 Byte1
.field [2] public uint8 Byte2
.field [3] public uint8 Byte3
}
Les [0], [0], [0], [1], [2], [3] devant chaque champ sont les décalages de FieldLayout. Trois champs à [0], c”est l”union. .size 4 est le ClassLayout.ClassSize ; .pack 0 est l”empaquetage par défaut. Rien de tout cela n”apparaît dans un .method : c”est de la métadonnée purement déclarative.
Ce que produit un décompilateur naïf
Un décompilateur bâti autour des corps de méthodes n”a rien sur quoi s”ancrer ici, car aucun corps de méthode n”est impliqué dans la disposition. Les modes de défaillance sont silencieux et dangereux :
// FAUX — type de disposition supprimé, décalages perdus
public struct Register32
{
public int AsInt;
public float AsFloat;
public byte Byte0;
public byte Byte1;
public byte Byte2;
public byte Byte3;
}
Cela compile. Cela a même les bons noms et types de champs. Mais l”attribut disparu, le code revient à la valeur par défaut du compilateur — Sequential — et les champs sont désormais disposés bout à bout : AsInt à 0, AsFloat à 4, les octets à 8, 9, 10, 11. Le struct fait maintenant 12 octets de large au lieu de 4, AsInt et AsFloat ne s”aliasent plus, et le cast de réinterprétation est mort. Chaque P/Invoke qui passait ce struct marshalle désormais un blob différent et plus grand. Rien ne vous avertit : les types concordent, le code compile, et il lit et écrit les mauvais octets pour toujours.
Une défaillance plus subtile conserve [StructLayout(LayoutKind.Explicit)] mais recalcule les décalages à partir des tailles de champ au lieu de lire FieldLayout. Cela donne des décalages sans chevauchement (0, 4, 8, 9, 10, 11), qui détruisent l”union tout aussi complètement tout en paraissant plus convaincants. La seule source correcte d”un décalage est le décalage des métadonnées. Il doit être copié, jamais dérivé.
La figure ci-dessous oppose les deux dispositions — la véritable union, où trois champs aliasent les mêmes quatre octets, face à la disposition séquentielle qu”un attribut supprimé produit silencieusement :
Comment Glass le reconstruit
Glass traite la disposition comme une préoccupation de métadonnées de premier ordre, distincte de la décompilation des méthodes. Pour chaque type, il exécute un petit pipeline explicite :
- Lire le
LayoutMaskdes flags duTypeDef. S”il estExplicitLayoutouAutoLayout, un attribut[StructLayout]est requis. S”il estSequentialLayoutet qu”il n”y a pas de ligneClassLayout, la disposition est celle par défaut du compilateur pour un struct et l”attribut est omis : l”émettre serait redondant. - Lire la ligne
ClassLayout, si elle existe, pourPackingSizeetClassSize. Un pack non nul devientPack = n; une taille non nulle devientSize = n. Les valeurs nulles sont laissées de côté, de sorte que l”attribut ne porte que ce que les métadonnées spécifient réellement. - Lire chaque ligne
FieldLayoutet attacher l”Offsetexact en octets à chaque champ sous forme de[FieldOffset(n)]. Ces décalages sont copiés littéralement des métadonnées : Glass ne les recalcule jamais à partir des tailles de champ. - Détecter les unions en parcourant les décalages collectés : chaque fois que les plages d”octets de deux champs (
offsetàoffset + size) se chevauchent, ce chevauchement est préservé exactement tel qu”écrit, ce qui fait de la sortie une union fidèle.
Le résultat est le source par lequel vous avez commencé, équivalent octet par octet dans la disposition :
[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
[FieldOffset(0)] public int AsInt;
[FieldOffset(0)] public float AsFloat;
[FieldOffset(0)] public byte Byte0;
[FieldOffset(1)] public byte Byte1;
[FieldOffset(2)] public byte Byte2;
[FieldOffset(3)] public byte Byte3;
}
Comme les décalages venaient directement de FieldLayout et le Size directement de ClassLayout, le struct recompilé fait le marshalling vers la même empreinte identique de quatre octets. La réinterprétation int/float fonctionne, chaque accesseur d”octet tombe là où il tombait, et tout P/Invoke qui passait Register32 passe toujours les mêmes octets.
Superpositions d”union étiquetée
La même mécanique gère le motif plus riche de l”« union étiquetée », où un champ discriminateur se place à son propre décalage et où plusieurs champs de charge utile se chevauchent plus loin :
[StructLayout(LayoutKind.Explicit)]
public struct Variant
{
[FieldOffset(0)] public VariantKind Kind; // discriminateur
[FieldOffset(8)] public long IntValue; // charge utile, aliasée...
[FieldOffset(8)] public double RealValue; // ...avec celle-ci
[FieldOffset(8)] public IntPtr Handle; // ...et celle-ci
}
Ici Kind au décalage 0 est lu pour décider lequel des trois champs de charge utile qui se chevauchent au décalage 8 est actif. Glass lit quatre lignes FieldLayout, voit que trois d”entre elles partagent le décalage 8, et préserve les quatre décalages. Cette fois il n”y a pas de ligne Size, donc aucun Size = n”est émis ; le runtime calcule le total à partir des champs. La reconstruction reste minimale — seuls les attributs que les métadonnées justifient réellement — tout en gardant chaque décalage intact.
L”exécuter
Pointez Glass sur l”assembly et demandez le type unique :
glass decompile MyApp.dll --type MyApp.Interop.Register32
La sortie est le struct avec attributs ci-dessus, décalages et taille compris. Pour balayer tout un namespace d”interop et confirmer que chaque type à disposition explicite fait l”aller-retour avec ses décalages intacts :
glass decompile MyApp.dll --namespace MyApp.Interop --layout-check
--layout-check relit les tables ClassLayout et FieldLayout après la reconstruction et vérifie que les valeurs émises de [FieldOffset]/Size/Pack correspondent exactement aux métadonnées, de sorte que vous avez une garantie — pas un espoir — que la disposition décompilée est la vraie. Glass est un décompilateur libre et ouvert ; c”est une sortie standard, rien de restreint.
Pourquoi cela vaut la peine dӐtre fait exactement bien
La disposition explicite est une promesse faite à quelque chose du monde non managé. La raison même pour laquelle un struct porte [StructLayout(LayoutKind.Explicit)] est que ses octets doivent s”aligner sur une définition qu”il ne possède pas : un en-tête C, un format de câble, une carte de registres. Un décompilateur qui rend les champs correctement mais perd les décalages a produit du code qui semble correct, compile correctement et est faux exactement de la manière la plus difficile à détecter : la corruption des données se produit à la frontière du marshalling, loin de toute ligne que vous penseriez à inspecter, et ni le compilateur ni le runtime ne diront un mot.
La règle que suit Glass est donc simple et stricte : la disposition est de la métadonnée, la métadonnée est la vérité de référence, et les décalages sont copiés, jamais inférés. Lire le LayoutMask, lire ClassLayout, lire chaque ligne FieldLayout, et réémettre précisément ce qui s”y trouve — jusqu”aux décalages qui se chevauchent et qui font d”une union une union. Quand vous inspectez ou déboguez du code d”interop que vous avez le droit de lire, cette fidélité fait la différence entre un source auquel vous pouvez vous fier et un source qui ment silencieusement sur l”endroit où vivent ses octets.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.