Decompilar fixed: locales pinned, la guarda de array nulo o vacío y GetPinnableReference
No hay sentencia fixed en IL. Lo que el compilador de C# emite en su lugar es una variable local con el modificador pinned en su firma, unas pocas instrucciones que calculan un puntero a partir de lo que hayas fijado — con guardas que nunca escribiste — y un almacenamiento que limpia la local al final del bloque. No hay try/finally, porque un pin es una propiedad del tiempo de vida de una local y no una región de código. El inicializador se rebaja de cuatro maneras distintas según lo que fijes: un array recibe una comprobación de nulo o vacío y un ldelema del elemento cero, una cadena recibe o bien la aritmética heredada de OffsetToStringData o bien una llamada a GetPinnableReference, un Span o cualquier tipo con un método GetPinnableReference recibe esa llamada sobre su dirección, y &campo o &local recibe un simple ldflda o ldloca. Este artículo lee el IL real de cada forma, explica por qué un array vacío produce un puntero nulo en lugar de una excepción, y muestra cómo Glass.NET se ancla en la local pinned para plegar la maquinaria de vuelta en el fixed de una línea que escribiste.
fixed es la pieza de sintaxis más pequeña del kit de herramientas unsafe de C# y una de las cosas más interesantes que encontrar en IL, porque casi nada de ella sobrevive. No hay un opcode fixed. No hay una instrucción pinned. No hay try/finally — a diferencia de using y foreach, que generan uno para su limpieza, un bloque fixed no tiene ninguna llamada de limpieza que hacer. Lo que hay en su lugar es una variable local con un modificador de tipo inusual, unas pocas instrucciones que calculan un puntero y un almacenamiento que limpia la local cuando terminas.
Y el inicializador — lo que va después del = — se rebaja de cuatro maneras distintas según lo que hayas fijado. Un decompilador tiene que reconocer cada una para imprimir la única línea que escribiste. Este artículo lee el IL de las cuatro y muestra cómo Glass.NET vuelve a fixed.
El ancla: una local con el modificador pinned
Todo cuelga de un único dato de metadatos. Toma el caso más simple:
unsafe int First(byte[] data)
{
fixed (byte* p = data)
{
return *p;
}
}
La firma de variables locales del método — el blob LocalVarSig al que apunta la cabecera del método — declara dos locales:
.locals init (
[0] uint8* p,
[1] uint8& pinned
)
La local 1 es una referencia gestionada a un byte (uint8&) que lleva la restricción pinned. pinned es un modificador de tipo de elemento en la codificación de la firma (ELEMENT_TYPE_PINNED, 0x45), y significa exactamente una cosa para el runtime: mientras esta local contenga una referencia no nula, el recolector de basura no puede mover el objeto al que apunta. Ese es todo el mecanismo. No hay ninguna instrucción que fije; la local fija, mientras esté viva y no sea nula.
Por eso también fixed es recuperable sin un PDB. El modificador pinned forma parte de los metadatos de firma del método, que el runtime necesita y que por tanto van en cada ensamblado, con símbolos de depuración o sin ellos. Un decompilador que encuentra una local pinned sabe que aquí hubo un fixed; el resto es averiguar qué se fijó y durante cuánto tiempo.
Forma 1: un array, y la guarda que nunca escribiste
Aquí está el cuerpo de First, y es más largo de lo que el código fuente sugeriría:
IL_0000: ldarg.1
IL_0001: dup
IL_0002: stloc.2 // hidden temp = data
IL_0003: brfalse.s IL_000a // data == null → null pointer
IL_0005: ldloc.2
IL_0006: ldlen
IL_0007: conv.i4
IL_0008: brtrue.s IL_000f // data.Length != 0 → pin it
IL_000a: ldc.i4.0
IL_000b: conv.u
IL_000c: stloc.0 // p = null
IL_000d: br.s IL_0019
IL_000f: ldloc.2
IL_0010: ldc.i4.0
IL_0011: ldelema [System.Runtime]System.Byte
IL_0016: stloc.1 // pinned = ref data[0] ← the pin begins
IL_0017: ldloc.1
IL_0018: conv.u
IL_0019: stloc.0 // p = (byte*)pinned
IL_001a: ldloc.0
IL_001b: ldind.u1 // *p
IL_001c: stloc.3
IL_001d: ldc.i4.0
IL_001e: conv.u
IL_001f: stloc.1 // pinned = null ← the pin ends
IL_0020: ldloc.3
IL_0021: ret
Dos cosas de ahí no están en tu código fuente.
La primera es la guarda de nulo o vacío. Tú escribiste fixed (byte* p = data) y el compilador emitió una comprobación de nulo y una comprobación de longitud antes de fijar nada. La razón es la regla del lenguaje para arrays: tomar &data[0] de un array vacío lanzaría IndexOutOfRangeException, y la especificación define en su lugar que fixed sobre un array nulo o de longitud cero produce un puntero nulo. Así que el código rebajado es de tres vías — array nulo, array vacío, array real — y solo la tercera ruta ejecuta realmente ldelema y almacena en la local pinned. Las otras dos almacenan un puntero nulo en p y nunca fijan nada. Merece la pena saberlo como lector de código unsafe: *p en la ruta del array vacío desreferencia null, y el fallo ocurre en el uso, no en el fixed.
La segunda es el almacenamiento de limpieza en IL_001d–IL_001f. El compilador almacena una referencia nula en la local pinned al final normal del bloque. No es un requisito de corrección — el pin terminaría de todos modos cuando la local muriera o el marco se desenrollara — es el compilador terminando el pin con prontitud, para que el GC pueda compactar el array en cuanto hayas terminado en lugar de a la salida del método. En compilaciones optimizadas donde al bloque le sigue inmediatamente ret, el compilador a veces lo omite, y por eso un decompilador debe tratar el almacenamiento de limpieza como opcional: su ausencia significa «el método terminó», no «esto no era un fixed».
Forma 2: una cadena, en dos generaciones
Las cadenas se pueden fijar desde C# 1, y el rebajado cambió por el camino, así que un decompilador se encuentra dos formas para la misma línea de código fuente:
unsafe int FirstChar(string s)
{
fixed (char* p = s)
return p == null ? -1 : *p;
}
La forma heredada — lo que obtienes al apuntar a .NET Framework, y lo que las versiones antiguas de Roslyn emitían en todas partes — fija el propio objeto string y hace aritmética de punteros para llegar a sus caracteres:
.locals init (
[0] char* p,
[1] string pinned
)
IL_0000: ldarg.1
IL_0001: stloc.1 // pinned = s (the whole object is pinned)
IL_0002: ldloc.1
IL_0003: conv.u
IL_0004: stloc.0 // p = (char*)object address
IL_0005: ldloc.0
IL_0006: brfalse.s IL_0010 // s == null → leave p null
IL_0008: ldloc.0
IL_0009: call int32 [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::get_OffsetToStringData()
IL_000e: add
IL_000f: stloc.0 // p += offset of the first char inside the string object
...
IL_00xx: ldnull
IL_00xx: stloc.1 // pinned = null
La local pinned aquí es de tipo string — una referencia a objeto pinned en lugar de un puntero gestionado pinned — y RuntimeHelpers.OffsetToStringData es la respuesta del runtime a «cuántos bytes hay desde el inicio de un objeto String hasta su primer char». El compilador suma eso a la dirección del objeto para aterrizar en los caracteres. La guarda de nulo está ahí porque OffsetToStringData sumado a una dirección nula produciría un puntero no nulo falso; fixed sobre una cadena nula debe dar null.
La forma moderna — .NET Core 3.0 y posteriores, donde string expone GetPinnableReference() — prescinde por completo de la aritmética:
.locals init (
[0] char* p,
[1] char& pinned
)
IL_0000: ldarg.1
IL_0001: dup
IL_0002: stloc.2
IL_0003: brtrue.s IL_000b // s != null → pin
IL_0005: ldc.i4.0
IL_0006: conv.u
IL_0007: stloc.0 // p = null
IL_0008: br.s IL_0015
IL_000b: ldloc.2
IL_000c: callvirt instance char& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute)
[System.Runtime]System.String::GetPinnableReference()
IL_0011: stloc.1 // pinned = ref s[0] (a ref into the string's chars)
IL_0012: ldloc.1
IL_0013: conv.u
IL_0014: stloc.0 // p = (char*)pinned
IL_0015: ...
IL_00xx: ldc.i4.0
IL_00xx: conv.u
IL_00xx: stloc.1 // pinned = null
Ahora la local pinned es un char& — un puntero gestionado directamente al primer carácter — y el método que lo produjo es string.GetPinnableReference(), cuyo tipo de retorno es ref readonly char (el modreq(InAttribute) en la firma IL es como se escribe ref readonly en los metadatos). Sin desplazamiento, sin aritmética. La misma línea de código fuente, dos secuencias de instrucciones completamente distintas, y ambas las debe mapear un decompilador de vuelta a fixed (char* p = s). (La disposición exacta de los saltos varía un poco entre versiones de Roslyn; las huellas — la local pinned, la guarda de nulo, la llamada a GetPinnableReference o la suma de OffsetToStringData — no.)
Forma 3: spans y el patrón GetPinnableReference
C# 7.3 generalizó la forma moderna de las cadenas en un patrón: cualquier tipo con un método accesible GetPinnableReference() que devuelva ref T o ref readonly T puede ser el operando de fixed, y Span<T>, ReadOnlySpan<T> y los tipos cercanos a Memory<T> tienen todos uno.
unsafe int Sum(ReadOnlySpan<int> values)
{
int total = 0;
fixed (int* p = values)
for (int i = 0; i < values.Length; i++) total += p[i];
return total;
}
.locals init (
[0] int32 total,
[1] int32* p,
[2] int32& pinned,
[3] int32 i
)
IL_0002: ldarga.s values
IL_0004: call instance !0& modreq(InAttribute) valuetype [System.Runtime]System.ReadOnlySpan`1<int32>::GetPinnableReference()
IL_0009: stloc.2 // pinned = ref values[0] (or a null ref for an empty span)
IL_000a: ldloc.2
IL_000b: conv.u
IL_000c: stloc.1 // p = (int*)pinned
...
IL_00xx: ldc.i4.0
IL_00xx: conv.u
IL_00xx: stloc.2 // pinned = null
Destacan dos diferencias respecto a la versión de cadena. El receptor es un tipo valor, así que la llamada se hace sobre ldarga.s values — la dirección del span — con un call simple, y no hay guarda de nulo: un struct no puede ser nulo, y Span<T>.GetPinnableReference() está especificado para devolver por sí mismo una referencia nula para un span vacío (así que p sale nulo con una entrada vacía, igual que con la regla de los arrays, pero por el contrato del método llamado y no por una guarda del llamador). El trabajo del decompilador es por tanto ligeramente distinto por forma: para un array tiene que reconocer y absorber la guarda; para un span tiene que reconocer que la ausencia de guarda es normal.
El patrón se aplica también a tus propios tipos. Dale a un struct un public ref byte GetPinnableReference() — o proporciónalo como método de extensión — y fixed (byte* p = myBuffer) se compila al mismo trío call/stloc pinned/conv.u. Un decompilador que lee una llamada a un método llamado GetPinnableReference cuyo resultado se almacena en una local pinned puede plegarla independientemente del tipo que lo declara, que es exactamente la regla con duck typing que usó el compilador.
Forma 4: una dirección explícita
La última forma es la que menos rebajado tiene, porque la dirección la proporcionaste tú mismo:
struct Header { public int Magic; public int Length; }
unsafe int ReadLength(Header[] headers)
{
fixed (int* p = &headers[3].Length)
return *p;
}
.locals init ([0] int32* p, [1] int32& pinned)
IL_0000: ldarg.1
IL_0001: ldc.i4.3
IL_0002: ldelema Header
IL_0007: ldflda int32 Header::Length
IL_000c: stloc.1 // pinned = ref headers[3].Length
IL_000d: ldloc.1
IL_000e: conv.u
IL_000f: stloc.0
ldelema y ldflda producen la referencia gestionada directamente; va a la local pinned, y luego con conv.u al puntero. Sin guarda — el indexador lanza por su cuenta si 3 está fuera de rango, y un acceso a campo sobre un objeto nulo lanza por su cuenta — así que el compilador no añade nada. Un decompilador reconoce esta forma por eliminación: una local pinned inicializada desde una instrucción que produce una dirección y que no es ni el elemento cero detrás de una guarda ni una llamada a GetPinnableReference es un &expr explícito, y la expresión es lo que sea que decompile el cálculo de la dirección.
Los búferes de tamaño fijo — fixed int Values[8]; dentro de un struct — son una característica distinta que da la casualidad de que comparte la palabra clave; producen un tipo de búfer anidado y ldflda contra él, y en targets modernos están siendo desplazados por los inline arrays. Decompilar inline arrays y stackalloc cubre ese lado.
Cómo lo pliega Glass.NET de vuelta
Juntando las cuatro formas, la recuperación de fixed en Glass.NET se ancla en lo único que todas comparten y después se despacha según el inicializador.
Los pasos, en orden:
- Encontrar las locales pinned. Están declaradas en la firma de locales, así que esto es una lectura de metadatos, no una inferencia. Cada local pinned es un
fixedcandidato. - Delimitar la región. Empieza en el primer almacenamiento de un valor no nulo en la local pinned y termina en el almacenamiento que la limpia —
ldc.i4.0; conv.u; stlocpara una ref pinned,ldnull; stlocpara un objeto pinned — o, cuando no existe almacenamiento de limpieza, en la salida del método. Roslyn reutiliza los slots pinned para sentenciasfixedsecuenciales en el mismo método, así que una local pinned puede delimitar varias regiones; cada almacenamiento de limpieza cierra una y el siguiente almacenamiento no nulo abre otra. - Clasificar el inicializador por la instrucción que produjo el valor almacenado en la local pinned:
ldelemacon índice constante0detrás de una guarda de nulo/longitud es un array; una llamada aGetPinnableReference(de cualquier tipo declarante) es la forma moderna de cadena/span/personalizada, con la guarda absorbida cuando el receptor es un tipo referencia; la aritmética deOffsetToStringDatasobre unstringpinned es la forma heredada de cadena; cualquier otra cosa que produzca una referencia gestionada es&expr, decompilada como la expresión que es. - Identificar la variable del fixed. Es la local de puntero no gestionado en la que se almacena el
conv.ude la local pinned. Su tipo de puntero declarado (uint8*,char*,int32*) se convierte en el tipo defixed (T* p = …), y los usos de ella en la región son usos dep. - Plegar — eliminar la guarda, el temporal, la local pinned, el
conv.uy el almacenamiento de limpieza; emitirfixed (T* p = operand) { region }.
Cuando una forma no encaja — la local pinned se lee por su propio valor dentro de la región, la guarda comprueba algo distinto del operando fijado, una local de puntero se asigna desde dos locales pinned distintas — Glass.NET se niega a plegar y muestra la forma rebajada en su lugar, con la local pinned anotada como // pinned. No hay sintaxis de C# para una local pinned, así que la anotación es la representación honesta; un fixed que tergiversara qué está fijado, o durante cuánto tiempo, sería peor que uno sin plegar.
Por qué importa cuando lees el código de otro
fixed aparece precisamente en el código que más quieres leer con cuidado: marshalling de P/Invoke, parsers hechos a mano sobre Span<byte>, códecs de imagen y audio, cualquier cosa que entregue un puntero a código nativo. Tres detalles del rebajado son lo que realmente estás comprobando cuando lo revisas. El puntero nulo del array vacío es una clase de bug clásica — el puntero es nulo y la primera desreferencia falla, lejos del fixed. El tiempo de vida del pin es el segundo: el pin termina cuando la local se limpia o el marco se desenrolla, así que un puntero que escapa del bloque — almacenado en un campo, entregado a un callback nativo que se dispara más tarde — apunta a memoria que el GC es libre de mover en el momento en que el método retorna. Y la ausencia de guarda en la forma de span significa que es el contrato del método llamado, no tu código, lo que mantiene seguro el caso vacío.
Un decompilador que imprime fixed (byte* p = data) te ha mostrado la intención. Un decompilador que además puede mostrarte la forma rebajada — la guarda, la local pinned, el almacenamiento de limpieza — te ha mostrado lo que el runtime hará realmente con ello, que para código unsafe es lo que importa.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.