Décompiler les initialiseurs de module : le constructeur statique de <Module> et ModuleInitializerAttribute
Une méthode marquée [ModuleInitializer] s'exécute avant tout le reste de l'assembly, mais ce n'est pas l'attribut que le runtime appelle. Le compilateur C# émet un constructeur statique sur le pseudo-type spécial <Module> qui appelle chaque initialiseur à son tour. Voici l'IL exact que génère le compilateur, les signaux de métadonnées qui marquent le cctor de <Module> et le distinguent d'un initialiseur de type ordinaire, et comment Glass.NET reconstruit le tout jusqu'aux méthodes [ModuleInitializer] que vous avez écrites.
Une méthode marquée [ModuleInitializer] s’exécute avant tout le reste de l’assembly : avant Main, avant que le premier champ statique ne soit touché, avant qu’aucun type ne soit utilisé. C’est le code utilisateur le plus précoce que le runtime exécutera. Mais ce n’est pas l’attribut que le runtime appelle. Ouvrez l’assembly dans un décompilateur et le mécanisme devient visible : un constructeur statique sur un type nommé <Module> que vous ne pouvez pas écrire à la main, qui appelle votre initialiseur à votre place.
Voici une plongée pratique dans ce mécanisme : ce que le compilateur C# émet pour une ou plusieurs méthodes [ModuleInitializer], les métadonnées qui marquent le constructeur de <Module> et le séparent d’un initialiseur de type ordinaire, et comment Glass.NET reconstruit le tout jusqu’aux méthodes attribuées d’origine. Glass est un décompilateur .NET gratuit, et tout ici est de l’analyse purement statique : lire les métadonnées et l’IL d’un assembly que vous avez le droit d’inspecter, sans jamais l’exécuter.
Le code source
Un seul initialiseur qui bascule un indicateur de disponibilité — aussi simple que possible :
using System.Runtime.CompilerServices;
namespace Acme.Diagnostics;
internal static class Bootstrap
{
internal static bool Ready;
[ModuleInitializer]
internal static void Init()
{
Ready = true;
}
}
La méthode doit satisfaire un ensemble fixe de règles ou le compilateur la rejette : elle doit être static, sans paramètre, renvoyer void, ne pas être générique ni se trouver dans un type générique, et être accessible au module (donc internal ou public, jamais private ni protected). Ces règles ne sont pas facultatives : marquez une méthode private avec [ModuleInitializer] et vous obtenez une erreur de compilation, pas un attribut silencieusement abandonné. Elles comptent ici parce qu’elles sont aussi la signature sur laquelle un décompilateur s’appuie pour reconnaître le répartiteur généré.
Ce que le compilateur émet
Votre méthode est laissée presque exactement telle que vous l’avez écrite, attribut compris. La partie intéressante est une seconde chose que le compilateur synthétise : un constructeur statique sur le type spécial <Module>. Décompilé vers une forme lisible, le constructeur généré n’est qu’un appel :
// synthétisé — aucune syntaxe C# ne peut déclarer ceci
static <Module>()
{
Acme.Diagnostics.Bootstrap.Init();
}
Le type <Module> n’est pas une classe que vous avez écrite ou que vous pourriez écrire. C’est le pseudo-type du module — la première ligne de la table de métadonnées TypeDef — le conteneur que le CLI réserve aux membres au niveau du module et à l’initialiseur de module. Il n’existe aucune syntaxe C# pour le déclarer ou lui donner un constructeur, ce qui est toute la raison d’être de [ModuleInitializer] : l’attribut est le seul moyen, au niveau du langage, d’injecter du code dans cet emplacement.
Voici l’IL, où les deux moitiés du motif sont visibles d’un coup :
.class private auto ansi '<Module>'
{
.method private hidebysig static specialname rtspecialname
void .cctor() cil managed
{
.maxstack 8
call void Acme.Diagnostics.Bootstrap::Init()
ret
}
}
.class private auto ansi sealed beforefieldinit
Acme.Diagnostics.Bootstrap extends [System.Runtime]System.Object
{
.method assembly hidebysig static void Init() cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ModuleInitializerAttribute::.ctor() = ( 01 00 00 00 )
.maxstack 8
ldc.i4.1
stsfld bool Acme.Diagnostics.Bootstrap::Ready
ret
}
}
Lisez-le de haut en bas. Le type <Module> porte un .cctor marqué specialname rtspecialname — les indicateurs de métadonnées qui identifient un constructeur spécial du runtime. Sur un type nommé, cette même forme est un constructeur statique ordinaire ; sur <Module>, c’est l’initialiseur de module. Son corps ne fait que call Bootstrap::Init puis retourne. La logique de l’initialiseur elle-même vit dans Bootstrap::Init, qui porte toujours la ligne .custom pour ModuleInitializerAttribute::.ctor() — l’attribut n’a jamais été consommé ni supprimé, il est laissé sur la méthode comme trace durable de l’intention.
Cette séparation est le fait clé pour quiconque lit le binaire : l’attribut marque la méthode de l’auteur, mais le runtime appelle le constructeur de <Module>. Le constructeur est la vérité fondamentale sur le moment où le code s’exécute ; l’attribut est la vérité fondamentale sur la raison de sa génération.
Plus d’un initialiseur
Rien ne vous empêche de marquer plusieurs méthodes, dans plusieurs types, avec [ModuleInitializer]. Le compilateur les rassemble toutes et émet un seul constructeur de <Module> dont le corps est une suite directe d’appels. Avec deux initialiseurs :
internal static class Logging
{
[ModuleInitializer]
internal static void RegisterProvider() { /* ... */ }
}
internal static class Formats
{
[ModuleInitializer]
internal static void RegisterResolver() { /* ... */ }
}
le constructeur généré devient :
.method private hidebysig static specialname rtspecialname
void .cctor() cil managed
{
.maxstack 8
call void Acme.Diagnostics.Logging::RegisterProvider()
call void Acme.Diagnostics.Formats::RegisterResolver()
ret
}
L’ordre des instructions call est un détail d’implémentation du compilateur — déterministe pour une compilation donnée, mais non spécifié par le langage entre fichiers —, vous ne devez donc pas compter sur l’exécution d’un initialiseur avant un autre. Ce qui compte pour lire le binaire, c’est la forme : un cctor de <Module> qui n’est *qu’*une liste de call vers des méthodes statiques, void, sans paramètre, chacune portant ModuleInitializerAttribute. C’est une empreinte sans équivoque, et c’est ce qui permet à un décompilateur d’attribuer chaque appel à sa méthode source au lieu de présenter un mystérieux constructeur au niveau du module.
Quand il s’exécute réellement
Le runtime traite l’initialiseur de module à peu près comme un initialiseur de type, mais à l’échelle du module. Il s’exécute exactement une fois, et il s’exécute au plus tard au premier accès au module — le premier appel de méthode statique, accès à un champ statique, instanciation de type ou invocation du point d’entrée. Son moment est précis (pas l’assouplissement paresseux beforefieldinit), vous pouvez donc compter sur son achèvement avant que tout autre code de l’assembly n’observe l’état. C’est ce qui en fait un point d’ancrage légitime à usage unique : enregistrer un fournisseur de journalisation, installer un résolveur de formats, préchauffer un cache. Quand vous en voyez un lors d’un audit, il vaut la peine de le lire attentivement précisément *parce qu’*il s’exécute en premier — c’est le code qui façonne l’environnement dans lequel tout le reste s’exécute.
Comment Glass le reconstruit
Un décompilateur naïf s’arrête aux métadonnées littérales : un type nommé <Module> avec un constructeur statique. C’est exact et inutile — vous ne pouvez pas déclarer un type <Module> en C#, donc la sortie ne se recompile pas, et la vraie logique est à un call de distance dans une méthode dont le lien avec le constructeur est invisible tant que vous ne le suivez pas.
Glass.NET reconnaît le motif. Il voit le cctor de <Module> composé uniquement d’appels vers des méthodes portant chacune ModuleInitializerAttribute, replie le constructeur synthétique et présente chaque cible telle que vous l’avez écrite — une méthode static void normale avec son attribut [ModuleInitializer] restauré. Vous lisez les méthodes attribuées, dans le module auquel elles appartiennent, sans aucun fantôme de <Module> dans l’arbre. Vous pouvez toujours descendre vers l’IL brut et voir le <Module>.cctor et sa liste d’appels par vous-même, chaque fois que vous voulez confirmer exactement ce que le runtime invoque et dans quel ordre.
Depuis la CLI, la même reconstruction tient en une commande. Pour reconstruire un projet compilable :
$ glass export ./Acme.Diagnostics.dll -o ./out/Acme.Diagnostics
ou pour ne récupérer que l’initialiseur et confirmer que l’attribut a survécu à l’aller-retour :
$ glass export ./Acme.Diagnostics.dll --member "M:Acme.Diagnostics.Bootstrap.Init"
Tous deux lisent les métadonnées et l’IL de l’assembly et reconstruisent du C# ; aucun n’en exécute une seule ligne.
À retenir en pratique
Quand vous ouvrez un assembly et trouvez un type nommé <Module> avec un constructeur statique — surtout un qui n’est rien d’autre qu’une liste d’instructions call —, vous n’avez rien d’exotique ni d’artisanal sous les yeux. Vous avez des initialiseurs de module : des méthodes ordinaires marquées [ModuleInitializer] que le compilateur a câblées dans l’unique emplacement de métadonnées qui s’exécute avant tout le reste. Trouvez le cctor de <Module> pour voir ce qui s’exécute en premier et dans quel ordre ; lisez les méthodes appelées (ou laissez Glass les replier vers leur forme attribuée) pour voir ce qu’elles font. Comme c’est le code le plus précoce de l’assembly, c’est souvent ce qu’il y a de plus intéressant à lire en premier lors d’un travail d’interopérabilité, d’un débogage ou d’un audit de sécurité. Glass.NET est gratuit et fait tout cela en lisant le binaire — vous pouvez donc voir exactement ce que fait au chargement un assembly que vous avez le droit d’analyser, sans jamais le charger.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.