Descompilar inicializadores de módulo: el constructor estático de <Module> y ModuleInitializerAttribute
Un método marcado con [ModuleInitializer] se ejecuta antes que cualquier otra cosa del ensamblado, pero no es el atributo lo que invoca el runtime. El compilador de C# emite un constructor estático en el pseudotipo especial <Module> que llama a cada inicializador por turno. Aquí está el IL exacto que genera el compilador, las señales de metadatos que marcan el cctor de <Module> y lo distinguen de un inicializador de tipo normal, y cómo Glass.NET lo reconstruye hasta los métodos [ModuleInitializer] que escribiste.
Un método marcado con [ModuleInitializer] se ejecuta antes que cualquier otra cosa del ensamblado: antes de Main, antes de que se toque el primer campo estático, antes de que se use ningún tipo. Es el código de usuario más temprano que el runtime ejecutará. Pero no es el atributo lo que invoca el runtime. Abre el ensamblado en un descompilador y el mecanismo se hace visible: un constructor estático sobre un tipo llamado <Module> que no puedes escribir a mano, que llama a tu inicializador por ti.
Esta es una inmersión práctica en ese mecanismo: qué emite el compilador de C# para uno o varios métodos [ModuleInitializer], los metadatos que marcan el constructor de <Module> y lo separan de un inicializador de tipo normal, y cómo Glass.NET lo reconstruye todo hasta los métodos con atributo con los que empezaste. Glass es un descompilador de .NET gratuito, y todo aquí es análisis puramente estático: leer metadatos e IL de un ensamblado que tienes derecho a inspeccionar, sin ejecutarlo nunca.
El código fuente
Un único inicializador que activa una bandera de preparación, tan sencillo como puede ser:
using System.Runtime.CompilerServices;
namespace Acme.Diagnostics;
internal static class Bootstrap
{
internal static bool Ready;
[ModuleInitializer]
internal static void Init()
{
Ready = true;
}
}
El método tiene que satisfacer un conjunto fijo de reglas o el compilador lo rechaza: debe ser static, sin parámetros, devolver void, no ser genérico ni estar dentro de un tipo genérico, y ser accesible para el módulo (es decir, internal o public, nunca private ni protected). Esas reglas no son opcionales: marca un método private con [ModuleInitializer] y obtienes un error de compilación, no un atributo descartado en silencio. Importan aquí porque son también la firma en la que se fija un descompilador para reconocer el despachador generado.
Qué emite el compilador
Tu método se deja casi exactamente como lo escribiste, atributo incluido. La parte interesante es una segunda cosa que el compilador sintetiza: un constructor estático sobre el tipo especial <Module>. Descompilado a una forma legible, el constructor generado es solo una llamada:
// sintetizado — ninguna sintaxis de C# puede declarar esto
static <Module>()
{
Acme.Diagnostics.Bootstrap.Init();
}
El tipo <Module> no es una clase que escribiste ni que podrías escribir. Es el pseudotipo del módulo —la fila uno de la tabla de metadatos TypeDef— el contenedor que el CLI reserva para los miembros a nivel de módulo y para el inicializador de módulo. No hay sintaxis de C# para declararlo ni para darle un constructor, que es toda la razón por la que existe [ModuleInitializer]: el atributo es la única vía a nivel de lenguaje para inyectar código en ese hueco.
Aquí está el IL, donde las dos mitades del patrón se ven a la vez:
.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
}
}
Léelo de arriba abajo. El tipo <Module> lleva un .cctor marcado como specialname rtspecialname, las banderas de metadatos que identifican un constructor especial del runtime. Sobre un tipo con nombre esa misma forma es un constructor estático corriente; sobre <Module> es el inicializador de módulo. Su cuerpo no hace más que call Bootstrap::Init y devolver. La lógica del inicializador vive en Bootstrap::Init, que todavía lleva la fila .custom de ModuleInitializerAttribute::.ctor(): el atributo nunca se consumió ni se eliminó, se deja en el método como registro duradero de la intención.
Esa separación es el hecho clave para quien lee el binario: el atributo marca el método del autor, pero el runtime llama al constructor de <Module>. El constructor es la verdad fundamental sobre cuándo se ejecuta el código; el atributo es la verdad fundamental sobre por qué se generó.
Más de un inicializador
Nada te impide marcar varios métodos, en varios tipos, con [ModuleInitializer]. El compilador los recopila todos y emite un único constructor de <Module> cuyo cuerpo es una secuencia directa de llamadas. Dados dos inicializadores:
internal static class Logging
{
[ModuleInitializer]
internal static void RegisterProvider() { /* ... */ }
}
internal static class Formats
{
[ModuleInitializer]
internal static void RegisterResolver() { /* ... */ }
}
el constructor generado queda así:
.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
}
El orden de las instrucciones call es un detalle de implementación del compilador —determinista para una compilación dada, pero no algo que el lenguaje especifique entre archivos—, así que no deberías depender de que un inicializador se ejecute antes que otro. Lo que importa para leer el binario es la forma: un cctor de <Module> que es solo una lista de call a métodos estáticos, void, sin parámetros, cada uno de los cuales lleva ModuleInitializerAttribute. Esa es una huella inconfundible, y es lo que permite a un descompilador atribuir cada llamada de vuelta a su método fuente en lugar de presentar un misterioso constructor a nivel de módulo.
Cuándo se ejecuta realmente
El runtime trata el inicializador de módulo de forma muy parecida a un inicializador de tipo, pero en el ámbito del módulo. Se ejecuta exactamente una vez, y se ejecuta a más tardar en el primer acceso al módulo: la primera llamada a un método estático, acceso a campo estático, instanciación de tipo o invocación del punto de entrada. Su temporización es precisa (no la relajación perezosa de beforefieldinit), así que puedes confiar en que ha terminado antes de que cualquier otro código del ensamblado observe el estado. Eso es lo que lo convierte en un gancho legítimo de una sola vez: registrar un proveedor de registro, instalar un resolutor de formatos, precalentar una caché. Cuando ves uno durante una auditoría, vale la pena leerlo con atención precisamente porque se ejecuta primero: es el código que da forma al entorno en el que todo lo demás se ejecuta.
Cómo lo reconstruye Glass
Un descompilador ingenuo se detiene en los metadatos literales: un tipo llamado <Module> con un constructor estático. Eso es exacto e inútil: no puedes declarar un tipo <Module> en C#, así que la salida no vuelve a compilar, y la lógica real está a una call de distancia en un método cuya conexión con el constructor es invisible salvo que la sigas.
Glass.NET reconoce el patrón. Ve el cctor de <Module> compuesto únicamente de llamadas a métodos que llevan cada uno ModuleInitializerAttribute, pliega el constructor sintético y presenta cada destino tal como lo escribiste: un método static void normal con su atributo [ModuleInitializer] restaurado. Lees los métodos con atributo, en el módulo al que pertenecen, sin ningún fantasma de <Module> en el árbol. Todavía puedes bajar al IL en bruto y ver el <Module>.cctor y su lista de llamadas por ti mismo siempre que quieras confirmar exactamente qué invoca el runtime y en qué orden.
Desde la CLI la misma reconstrucción es un comando. Para reconstruir un proyecto compilable:
$ glass export ./Acme.Diagnostics.dll -o ./out/Acme.Diagnostics
o para extraer solo el inicializador y confirmar que el atributo sobrevivió al viaje de ida y vuelta:
$ glass export ./Acme.Diagnostics.dll --member "M:Acme.Diagnostics.Bootstrap.Init"
Ambos leen los metadatos y el IL del ensamblado y reconstruyen C#; ninguno ejecuta una sola línea.
La conclusión práctica
Cuando abres un ensamblado y encuentras un tipo llamado <Module> con un constructor estático —sobre todo uno que no es más que una lista de instrucciones call—, no estás ante nada exótico ni hecho a mano. Estás ante inicializadores de módulo: métodos corrientes marcados con [ModuleInitializer] que el compilador conectó al único hueco de metadatos que se ejecuta antes que todo lo demás. Busca el cctor de <Module> para ver qué se ejecuta primero y en qué orden; lee los métodos llamados (o deja que Glass los pliegue de vuelta a su forma con atributo) para ver qué hacen. Como este es el código más temprano del ensamblado, a menudo es lo más interesante de leer primero durante un trabajo de interoperabilidad, una depuración o una auditoría de seguridad. Glass.NET es gratuito y hace todo esto leyendo el binario, así que puedes ver exactamente qué hace al cargarse un ensamblado que tienes derecho a analizar, sin cargarlo nunca.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.