Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Decompilar inline arrays y stackalloc de C# 12: búferes de tamaño fijo sin el unsafe

Los inline arrays de C# 12 te dan un búfer de tamaño fijo, de tipo valor, que vive en línea dentro de su struct contenedor —lo que C llamaba `int buf[8]`—, pero en IL es un struct de un solo campo decorado con [InlineArray(8)], accedido mediante intrínsecos del runtime y Span<T> en vez de cualquier instrucción de array. stackalloc, por su parte, compila a un localloc crudo que un decompilador ingenuo muestra como un puntero y un montón de opcodes stind. Equivócate en cualquiera de los dos y el decompilador emite aritmética de punteros unsafe donde la fuente tenía un indexador limpio, o inventa un array que nunca se asignó. Aquí tienes exactamente qué emite el compilador para un tipo [InlineArray], cómo el acceso a elementos baja a un helper de PrivateImplementationDetails sobre un Span, cómo se ven localloc y el constructor Span(void*, int) en IL, y cómo Glass.NET los lee de vuelta a las declaraciones de inline array y las expresiones stackalloc que realmente escribiste.

C# 12 añadió los inline arrays, y son fáciles de malinterpretar incluso en fuente: un búfer de tamaño fijo que vive dentro de su struct contenedor, de tipo valor de principio a fin, sin asignación en heap, sin objeto de array. Es la respuesta gestionada y segura al int buf[8] del programador de C: lo que antes solo podías obtener con un búfer de tamaño fijo unsafe. Emparejado con stackalloc, que ha sido modernizado silenciosamente para entregarte un Span<T> en vez de un puntero crudo, ahora puedes escribir código intensivo en búferes y sin asignaciones sin una sola palabra clave unsafe.

Esa seguridad es una ilusión a nivel de fuente construida por el compilador, y la ilusión es exactamente lo que un decompilador tiene que reconstruir. Por debajo, un inline array no es un array: es un struct de un solo campo con un atributo que le dice al runtime que distribuya N copias del campo. El acceso a elementos no es ldelem: es una llamada a un intrínseco del runtime que devuelve una referencia gestionada. Y stackalloc no es nada seguro por debajo: es un localloc, la asignación de pila más cruda que tiene el CLR, envuelta en un constructor de Span para que el puntero nunca llegue a tus ojos. Un decompilador que baja al metal y se detiene te muestra aritmética de ref, llamadas a helpers con nombres que no puedes teclear, y bloques unsafe que nunca estuvieron en la fuente. El código que emite a menudo ni siquiera recompila.

Esta entrada recorre el IL de ambas características y muestra qué debe reconocer un decompilador para volver a armar la fuente.

Un inline array es un struct que lleva puesto un atributo

Aquí está la declaración y un uso trivial:

[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer8
{
    private int _element0;
}

public static int Sum(Buffer8 buf)
{
    int total = 0;
    for (int i = 0; i < 8; i++)
        total += buf[i];
    return total;
}

Buffer8 tiene un campo, _element0, y un atributo, [InlineArray(8)]. Ese es todo el truco: el atributo instruye al runtime a asignar el campo ocho veces de forma contigua, así que un Buffer8 son 32 bytes y buf[3] significa “el cuarto int de ese bloque”. No hay objeto de array ni palabra de longitud: la longitud está horneada en el atributo, conocida en tiempo de compilación, y nunca se almacena en tiempo de ejecución.

En metadatos el tipo es un sealed value type liso con un único campo. Nada en la definición del tipo dice “array”; la única señal es el atributo personalizado:

.class public sequential ansi sealed beforefieldinit Buffer8
    extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.InlineArrayAttribute::.ctor(int32)
        = ( 01 00 08 00 00 00 00 00 )          // InlineArray(8)
    .field private int32 _element0
}

Un decompilador que clasifica los tipos por su forma ve un struct de un campo y, a menos que lea ese atributo, lo imprime como un struct de un campo, perdiendo todo el significado. Así que la primera regla de reconocimiento es: un tipo valor que lleva [InlineArray(N)] es un inline array del tipo de su único campo, longitud N, y debe renderizarse con el atributo y el único campo de respaldo exactamente como los emitió el compilador.

La indexación baja a un intrínseco del runtime, no a ldelem

Ahora el acceso. buf[i] compila no a una carga de array, sino a una llamada que produce una referencia gestionada al elemento i. El compilador la enruta a través de un helper generado en <PrivateImplementationDetails> que envuelve RuntimeHelpers.InlineArrayElementRef (o construye un Span<T> sobre el búfer con InlineArrayAsSpan y lo indexa, según el contexto). La lectura dentro del bucle baja aproximadamente a:

// total += buf[i];
ldarga.s   buf
ldloc      i
call       !!1& [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InlineArrayElementRef<valuetype Buffer8, int32>(!!0&, int32)
ldind.i4
add

La llamada a InlineArrayElementRef toma una referencia al búfer y un índice, y devuelve un byref al elemento; ldind.i4 luego lo desreferencia para cargar el int. Para una escritura verías stind.i4 contra el mismo tipo de ref. No hay ldelem, ni opcode de comprobación de límites, nada que parezca un array: solo una llamada intrínseca que devuelve un ref.

Un decompilador ingenuo lo renderiza literalmente: una llamada a RuntimeHelpers.InlineArrayElementRef<Buffer8, int>(ref buf, i) seguida de una desreferencia, o peor, una referencia a un helper de <PrivateImplementationDetails> cuyo nombre contiene caracteres que no puedes escribir en C#. En cualquier caso la salida no dice buf[i] y no recompila. La regla de reconocimiento es: una llamada al intrínseco de ref de elemento de inline array (o al helper generado a su alrededor) contra un tipo [InlineArray], seguida de una carga o almacenamiento a través del ref devuelto, es un acceso de indexador: renderízalo como buf[i]. Lo mismo aplica a la forma InlineArrayAsSpan, que un decompilador debería plegar de vuelta ya sea a un acceso de elemento o a una vista Span<T> del búfer, según cómo se use el resultado.

fuentebuf[3]IL[InlineArray(8)] struct { int _element0 }call InlineArrayElementRef(ref,3) → ldind.i4decompilador ingenuoInlineArrayElementRef(ref buf,3)referencia <PrivateImplementationDetails> — no compilaGlass.NETbuf[3] // sobre Buffer8recupera el indexador — recompila

stackalloc es un localloc que lleva puesto un Span

Ahora la segunda característica. Un stackalloc moderno asignado a un Span<T> parece completamente seguro en fuente:

public static int SumFirstFour(ReadOnlySpan<int> input)
{
    Span<int> scratch = stackalloc int[4];
    for (int i = 0; i < 4; i++)
        scratch[i] = input[i] * input[i];
    int total = 0;
    foreach (int v in scratch) total += v;
    return total;
}

Sin unsafe, sin puntero. Pero stackalloc tiene exactamente una bajada: la instrucción localloc, que asigna un bloque en el marco de pila del método actual y empuja un puntero nativo a él. El compilador dimensiona el bloque (4 * sizeof(int)), asigna, luego construye un Span<int> sobre el puntero crudo y el número de elementos usando el constructor Span<T>(void*, int):

// Span<int> scratch = stackalloc int[4];
ldc.i4.4
conv.u
ldc.i4.4
mul.ovf.un          // 4 elements * 4 bytes
localloc            // -> native int (pointer to the stack block)
ldc.i4.4
newobj instance void valuetype [System.Runtime]System.Span`1<int32>::.ctor(void*, int32)
stloc.0             // scratch

Esa es la firma: un ldc de número de elementos, un multiplicador de tamaño (conv.u / mul.ovf.un), un localloc, y luego un newobj sobre Span<T>::.ctor(void*, int32). Un decompilador que renderiza esas instrucciones literalmente produce un método unsafe con un void*, una multiplicación, un intrínseco localloc (que C# ni siquiera puede expresar directamente), y un constructor de Span que toma un puntero. Nada de eso está en la fuente, y el void* fuerza un contexto unsafe que el autor evitó deliberadamente.

La regla de reconocimiento pliega toda la forma en una expresión: un localloc cuyo tamaño es count * sizeof(T), consumido por el constructor (void*, int) de Span<T> o ReadOnlySpan<T>, es stackalloc T[count]. Cuando el tamaño del tipo de elemento es una constante en tiempo de compilación y el número también, Glass reconstruye stackalloc int[4]; cuando el número es una variable, reconstruye stackalloc int[n]. El puntero crudo, la multiplicación y el constructor desaparecen todos de vuelta en el único stackalloc que tecleó el autor, y el método sigue siendo seguro: sin palabra clave unsafe, porque la fuente no tenía ninguna.

Hay una sutileza que vale la pena nombrar: no todo stackalloc se convierte en un Span. La forma más antigua, int* p = stackalloc int[4];, asigna el puntero directamente y es genuinamente unsafe: ahí el void* y el unsafe pertenecen a la salida, porque estaban en la fuente. El decompilador distingue ambas por lo que consume el localloc: un constructor de Span/ReadOnlySpan significa la forma segura, una asignación directa de puntero significa la forma unsafe. Acertar en esa distinción es la diferencia entre reproducir fielmente un método seguro y acusarlo falsamente de ser unsafe.

Por qué importa la recuperación fiel aquí en concreto

Para la mayoría de características del lenguaje, un decompilador que se equivoca ligeramente en los detalles produce código que es feo pero aún compila. Estas dos son distintas, porque ambas existen precisamente para darte rendimiento de bajo nivel mientras permaneces en C# seguro y de alto nivel, y la decompilación incorrecta destruye exactamente esa propiedad.

Un inline array renderizado como llamadas a InlineArrayElementRef referencia helpers en <PrivateImplementationDetails>, un tipo interno del compilador cuyos miembros tienen nombres que contienen < y > que son ilegales en fuente C#. Esa salida no se puede pegar de vuelta y compilar; es una descripción del IL, no una reconstrucción del programa. Un stackalloc renderizado como localloc más un void* convierte un método seguro en uno que requiere unsafe, tergiversando el contrato de seguridad del método y, de nuevo, a menudo fallando al compilar sin ediciones que el autor nunca hizo.

Glass lee ambos patrones al nivel al que trabajó el autor. Clasifica los structs [InlineArray(N)] como inline arrays y renderiza su acceso a elementos como indexadores; reconoce la forma localloc-más-constructor-de-Span como stackalloc y mantiene el método seguro, mientras aún distingue la forma de puntero genuinamente unsafe. La salida se lee como la fuente —buf[i] y stackalloc int[4]— y, igual de importante, recompila como la fuente, que es la verdadera prueba de si un decompilador entendió el programa o solo transcribió sus bytes.

Resumen

Los inline arrays y el stackalloc moderno son dos de los casos más claros en los que el IL es dramáticamente de más bajo nivel que el C#. Un inline array es un struct de un campo cuyo atributo [InlineArray(N)] es la única evidencia de que es un búfer, indexado a través de intrínsecos del runtime que devuelven byrefs en vez de cualquier opcode de array. Un stackalloc de tipo Span es un localloc crudo vestido con un constructor Span<T>(void*, int) para que el puntero nunca se muestre. Un decompilador se gana el sueldo recuperando la intención del autor: la declaración del inline array y sus indexadores, la única expresión stackalloc, y el cuerpo del método seguro y libre de unsafe que hizo que estas características merecieran la pena para empezar.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.