Decompilar campos ref y scoped: cómo se ven las ref struct de C# 11 en IL
Un campo ref es la primera vez que un campo de C# almacena una referencia en lugar de un valor, y se compila a metadatos que los decompiladores anteriores nunca tuvieron que leer. La firma del campo lleva un tipo de elemento byref —ELEMENT_TYPE_BYREF dentro de una firma FIELD, que era ilegal antes de .NET 7— y las reglas de seguridad ref que impiden que sobreviva a aquello a lo que apunta están codificadas en modreq y atributos, no en IL que puedas ejecutar. scoped es la más ruidosa de ellas: es un ScopedRefAttribute sobre un parámetro, sin efecto alguno en tiempo de ejecución, que un decompilador o bien muestra como la palabra clave scoped o bien descarta, cambiando en silencio el contrato de seguridad ref del método. Esta entrada lee las firmas en crudo y los blobs de atributos de un campo ref, de una ref struct que contiene uno, y de un parámetro scoped ref, muestra dónde viven en los metadatos las reglas de escape de ref, y recorre cómo Glass.NET reconstruye ref, ref readonly, scoped y la salida de emergencia [UnscopedRef] para que el código fuente decompilado compile con la misma seguridad que escribió el autor.
Un campo ref es la primera funcionalidad de C# que deja que un campo contenga una referencia en lugar de un valor, y es la primera que forzó un cambio en las propias reglas del runtime sobre qué puede contener la firma de un campo. Antes de .NET 7 un campo no podía ser un puntero gestionado; el runtime rechazaba los metadatos. Los tipos ref struct como Span<T> lo fingían con un intrínseco interno. C# 11 y .NET 7 lo hicieron real — y al hacerlo crearon metadatos que los decompiladores escritos para IL más antiguo simplemente nunca tuvieron que analizar.
El problema es que la mayor parte de lo que hace seguro a un campo ref no está en IL ejecutable en absoluto. Un campo ref puede apuntar a una variable local que sale de ámbito; las reglas que lo impiden —el análisis de escape de ref— corren enteramente en el compilador y dejan sus huellas en metadatos que el JIT ignora: un modreq aquí, un ScopedRefAttribute allá, un [UnscopedRef] sobre el único método al que se permite romper el valor por defecto. Un decompilador que lee solo las instrucciones produce código que corre de forma idéntica y expresa un contrato de seguridad diferente — y descubres la diferencia solo cuando lo recompilas y el compilador permite un escape que el autor prohibía, o prohíbe una devolución que el autor pretendía.
Esta entrada lee los metadatos en crudo de tres cosas — un campo ref, una ref struct que almacena uno, y un parámetro scoped — y muestra dónde vive la seguridad y cómo Glass.NET la convierte de vuelta en las palabras clave correctas.
Un campo ref es un BYREF en una firma FIELD
Empieza con la ref struct más pequeña que contenga un campo ref y lee lo que el compilador emite.
public ref struct IntRef
{
public ref int Value; // a ref field
public IntRef(ref int target) => Value = ref target; // ref assignment
}
La declaración del campo compila a un campo cuya firma no es la habitual “FIELD luego tipo de elemento”. Es FIELD, luego ELEMENT_TYPE_BYREF, luego el tipo de elemento:
.field public int32& Value
// ^^^^^^ the '&' is ELEMENT_TYPE_BYREF — a managed reference, in a FIELD sig
// signature bytes: 06 10 08
// 06 = FIELD
// 10 = ELEMENT_TYPE_BYREF
// 08 = ELEMENT_TYPE_I4 (int)
Ese byte 10 — un BYREF dentro de una firma de campo — es la funcionalidad entera a nivel de metadatos, y es exactamente lo que un runtime anterior a .NET 7 se negaba a cargar. Un decompilador más antiguo que recorre la firma de un campo esperando FIELD <tipo> lee el 10 como el tipo de elemento de una firma mal formada, o se lo salta, y muestra el campo como int32 Value a secas. El código fuente resultante declara un campo de valor, no un campo ref — descarta la única propiedad que hace que valga la pena escribir IntRef, y no compilará dentro de una ref struct como el mismo tipo.
La asignación ref en el constructor es su propia instrucción, distinta de un almacenamiento de valor:
// Value = ref target;
ldarg.0 // this
ldarg.1 // ref target (already a managed pointer)
stfld int32& IntRef::Value // store the REFERENCE into the ref field
El stfld apunta a un campo cuyo tipo es int32&. Contrasta con la versión de valor, this.Value = target, que haría ldind.i4 para desreferenciar target primero y luego stfld int32 en un campo de valor. Glass lee la firma byref del campo, reconoce el stfld en un campo byref como una asignación-ref, y reconstruye Value = ref target en lugar de Value = target — dos sentencias que difieren en una palabra clave y significan cosas enteramente distintas sobre tiempo de vida.
Los marcadores de ref struct a su alrededor
IntRef lleva los metadatos que dicen “esto es una ref struct”: es un tipo de valor marcado con dos atributos, y un decompilador necesita ambos para regenerar el modificador ref struct y la guarda de obsolescencia que mantiene alejados a los compiladores antiguos.
.class public sequential ansi sealed beforefieldinit IntRef
extends [System.Runtime]System.ValueType
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor()
.custom instance void [System.Runtime]System.ObsoleteAttribute::.ctor(string, bool)
= {string('Types with embedded references...') bool(true)}
}
IsByRefLikeAttribute es el marcador real — es lo que ref struct compila, y es lo que le dice al compilador que el tipo no puede boxearse, no puede ser un campo de una struct no-ref, no puede capturarse, y así sucesivamente. El [Obsolete(..., error: true)] con ese mensaje específico es una guarda que el compilador emite para que un compilador demasiado antiguo para entender los tipos byref-like se niegue a tocarlo en lugar de manejarlo mal en silencio — el mismo patrón de fallar-cerrado que ves con los miembros required. Glass mapea IsByRefLikeAttribute de vuelta al ref en ref struct y reconoce el [Obsolete] sintético emparejado como generado por el compilador, así que no aflora el atributo como si lo hubiera escrito el autor — pero sí preserva la palabra clave ref struct que lo produjo.
scoped: un contrato sin instrucciones
Ahora la parte que ninguna cantidad de leer IL recuperará, porque no hay IL que leer. scoped restringe adónde puede viajar una ref, y esa restricción se comprueba solo en tiempo de compilación.
public static ref int Risky(scoped ref int x, ref int y)
{
// return ref x; // compile ERROR: x is scoped, may not escape
return ref y; // ok: y is not scoped
}
scoped en el parámetro x compila a un atributo marcador sobre ese parámetro y no cambia ni un byte del cuerpo del método:
.method public static int32& Risky(
int32& x,
int32& y) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ScopedRefAttribute::.ctor()
// ^ parameter 1 (x) carries [ScopedRef]; parameter 2 (y) does not
...
}
Tanto x como y son int32& en la firma — parámetros byref idénticos en lo que a las instrucciones respecta. Lo único que distingue scoped ref de ref a secas es el ScopedRefAttribute sobre .param [1]. El JIT lo ignora por completo; el método corre igual con o sin él. Pero para el compilador de C# es una promesa de que x no escapará, que es lo que permite a quienes llaman pasar con seguridad una referencia a una variable local de corta vida como x.
Un decompilador que lista los parámetros solo desde la firma ve dos parámetros ref int y emite Risky(ref int x, ref int y). Eso compila, y corre de forma idéntica — pero ha ensanchado en silencio el contrato: el método regenerado ahora permite return ref x, un escape que el original prohibía explícitamente. Nadie lo nota hasta que alguien edita el código fuente decompilado, añade un return ref x, y el compilador que debería haberlo rechazado lo acepta, porque el scoped que lo habría detenido ya no está. Glass lee los atributos personalizados de .param junto con la firma y restaura scoped exactamente en los parámetros que llevan ScopedRefAttribute.
ref, in y ref readonly comparten un BYREF
La misma disciplina de “lee el modificador, no solo el byref” separa las tres clases de byref orientadas a lectura, que son indistinguibles en la firma en crudo.
void A(ref int x); // BYREF, no modifier
void B(in int x); // BYREF + modreq(InAttribute), param has [IsReadOnly]
void C(ref readonly int x); // BYREF + IsReadOnly signal, treated as ref-readonly (C# 12)
Los tres parámetros son int32&. in añade modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) a la firma y marca el parámetro [IsReadOnly]; ref readonly lleva la señal de solo lectura sin la lenidad específica de in en el punto de llamada, de modo que el compilador puede avisar cuando un argumento se pasa de una forma que in habría aceptado en silencio. Un decompilador que lee solo el BYREF colapsa los tres en ref, lo que cambia la firma de la sobrecarga y las reglas en cada punto de llamada. Glass inspecciona el modreq y el atributo [IsReadOnly] juntos y elige ref, in o ref readonly en consecuencia.
[UnscopedRef]: la salida de emergencia que debe sobrevivir
scoped aprieta; [UnscopedRef] afloja — y descartarlo rompe la compilación en lugar de debilitar un contrato en silencio, lo que lo convierte en el único de estos que un decompilador no puede permitirse perder en absoluto.
Algunas refs están implícitamente acotadas. El this de un método de instancia de struct es una: por defecto no puedes devolver una ref a un campo de la struct, porque la struct podría ser un temporal. Una struct que necesita entregar una ref a sí misma marca el método [UnscopedRef]:
public struct Buffer
{
private int _first;
[UnscopedRef] // without this, the return does not compile
public ref int First() => ref _first;
}
En los metadatos el método lleva UnscopedRefAttribute:
.method public hidebysig instance int32& First() cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.UnscopedRefAttribute::.ctor()
// body: ldarg.0; ldflda int32 Buffer::_first; ret
}
Si un decompilador descarta este atributo, el ref int First() => ref _first; regenerado es rechazado por el compilador — el alcance por defecto sobre this prohíbe devolver una ref a _first. Así que a diferencia de scoped, donde la pérdida es un ensanchamiento silencioso, la pérdida de [UnscopedRef] es un error de compilación ruidoso en el momento en que intentas reconstruir. De cualquier modo el código fuente decompilado ya no coincide con el programa del autor. Glass aflora [UnscopedRef] exactamente donde aparece, porque aquí el atributo no es decoración — es lo que hace legal al método, y el tipo regenerado no da la vuelta completa sin él.
Leerlo todo junto
El hilo conductor a través de los campos ref, scoped, in/ref readonly y [UnscopedRef] es que el modelo de seguridad ref de C# vive en tres lugares que al JIT no le importan: el BYREF en una firma (la ref en sí), el modreq a su lado (in frente a ref a secas), y los atributos a su alrededor (ScopedRefAttribute, IsReadOnlyAttribute, UnscopedRefAttribute). Un decompilador que lee solo las instrucciones ejecutables obtiene código que corre y un contrato de seguridad que es sutil o completamente incorrecto — reasignable donde el original era de una sola vez, escapable donde el original estaba acotado, o no compilable donde el original estaba bien. Glass.NET lee la firma, los modificadores personalizados y los atributos de parámetro y de método como una sola unidad, que es lo que hace falta para devolverte ref struct, ref int, scoped ref, ref readonly y [UnscopedRef] como los escribió el autor — código fuente que a la vez corre igual y lleva las mismas garantías de seguridad ref.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.