Descompilar literales de cadena UTF-8: el blob RVA detrás de "..."u8
Un literal "abc"u8 no es una cadena ni un array de bytes: es un ReadOnlySpan<byte> sobre un campo de datos RVA estático. Aquí está el IL, el terminador nulo oculto y lo que reconstruye Glass.NET.
Un literal "abc"u8 parece la funcionalidad más pequeña posible: toma una cadena, ponle un u8 al final, obtén sus bytes UTF-8. Pero lo que recibes de vuelta no es ni una string ni un byte[]. Es un ReadOnlySpan<byte>, y los bytes que abarca no viven en el heap en absoluto: están horneados en el ensamblado como un blob de datos estático y el span apunta directamente a ellos. Ese diseño es lo que hace que los literales u8 no asignen memoria, y también es lo que los hace interesantes de descompilar: el patrón en disco no se parece en nada al código fuente, y un descompilador que lo lea al pie de la letra produce algo irreconocible. Este artículo recorre toda la reducción (lowering) y muestra lo que reconstruye Glass.NET.
Cómo se ve el código fuente
Los literales de cadena UTF-8 llegaron en C# 11. Su objetivo es entregar bytes UTF-8 a las muchas API que quieren ReadOnlySpan<byte> —Utf8Formatter, Utf8Parser, sockets, pipelines, escritores de JSON— sin pagar por una string y una llamada a Encoding.UTF8.GetBytes en tiempo de ejecución en cada uso.
using System;
static class Protocol
{
public static ReadOnlySpan<byte> Preamble => "abc"u8;
}
La propiedad devuelve un span. No hay ningún campo de tipo string, ningún byte[], ni trabajo de codificación en tiempo de ejecución. Todo quedó resuelto en tiempo de compilación.
Lo que realmente emite el compilador
Dos cosas se escriben en el ensamblado. Primero, un campo estático que contiene los bytes codificados. Segundo, el IL que construye un span sobre ese campo. Aquí está el cuerpo del método, tal como lo muestra Glass en la vista de IL:
.method public hidebysig specialname static
valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>
get_Preamble () cil managed
{
.maxstack 8
IL_0000: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4'
'<PrivateImplementationDetails>'::'2E7D2C...A25AEFC6'
IL_0005: ldc.i4.3
IL_0006: newobj instance void [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
IL_000b: ret
}
Tres instrucciones. ldsflda empuja la dirección de un campo estático —no su valor, su dirección, como un void*—. ldc.i4.3 empuja la longitud. newobj llama al constructor no seguro (unsafe) de ReadOnlySpan<byte> que toma un puntero y una longitud. El span es ahora una ventana sobre los bytes del campo, y esos bytes nunca se mueven.
El campo y su blob RVA
El campo del que vino la dirección vive en el tipo generado por el compilador <PrivateImplementationDetails> —el mismo corral oculto donde van los inicializadores de arrays y otros blobs constantes—. Su tipo es un value type anidado con el nombre de su propio tamaño:
.class nested private explicit ansi sealed '__StaticArrayInitTypeSize=4'
extends [System.Runtime]System.ValueType
{
.pack 1
.size 4
}
.field static assembly valuetype
'<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4'
'2E7D2C...A25AEFC6' at I_00002A50
.data cil I_00002A50 = bytearray (61 62 63 00)
El campo no tiene un valor inicial en el sentido habitual; tiene un RVA —una dirección virtual relativa— que apunta a la sección de datos cruda del ensamblado, declarada aquí por .data. El blob es 61 62 63 00: a, b, c y un 00 final. El nombre del campo se deriva de un hash del contenido, por lo que dos literales u8 idénticos en cualquier parte del ensamblado comparten un solo campo.
El terminador nulo oculto
Fíjate de nuevo en los dos números que no coinciden. El tipo del campo es __StaticArrayInitTypeSize=4 y el blob son cuatro bytes, pero la longitud empujada antes del constructor es 3. Roslyn añade un único 0x00 a cada carga útil u8 y dimensiona el campo para incluirlo, pero construye el span solo sobre los bytes reales. Así que "abc"u8 son físicamente cuatro bytes en disco y lógicamente un span de Length 3.
Ese terminador es deliberado. Permite que los bytes incrustados sirvan también como una cadena terminada en NUL al estilo C para la interoperabilidad nativa que espera una, al coste de un solo byte. Tu span, sin embargo, se detiene en el índice 2: el 00 es almacén subyacente que los límites del span nunca alcanzan. Acertar con esta relación es exactamente el tipo de detalle que un descompilador tiene que codificar: el literal reconstruido debe contener los bytes de la carga útil y no el terminador.
El pipeline, de principio a fin
El compilador codifica, almacena y referencia; un descompilador tiene que ejecutar las mismas tres etapas a la inversa y reconocer que toda la forma es un único literal.
Lo que muestra un descompilador ingenuo — y lo que muestra Glass
Nada en las tres instrucciones dice «literal UTF-8». Un descompilador que reconstruye el IL al pie de la letra tiene un ReadOnlySpan<byte> construido a partir de la dirección de un campo opaco, y lo más honesto que puede imprimir es exactamente eso:
// reconstrucción literal — técnicamente correcta, ilegible
public static ReadOnlySpan<byte> Preamble =>
new ReadOnlySpan<byte>(
Unsafe.AsPointer(ref <PrivateImplementationDetails>.2E7D2C...A25AEFC6),
3);
Eso compila en espíritu pero no te dice nada, y filtra un tipo generado que nunca estuvo en tu código fuente. Glass, en cambio, compara el patrón. Cuando ve un ldsflda sobre un campo __StaticArrayInitTypeSize con datos RVA, que alimenta a un ReadOnlySpan<byte>..ctor(void*, int32) cuya longitud es el tamaño del blob menos un único 0x00 final, y los bytes decodifican como UTF-8 válido, reconstruye el literal:
static class Protocol
{
public static ReadOnlySpan<byte> Preamble => "abc"u8;
}
Si los bytes no son UTF-8 válido, o la longitud no cuadra con la convención del terminador, Glass no adivina: vuelve a mostrar el span sobre un blob de bytes, porque eso es lo que el ensamblado realmente significa. La reconstrucción solo se aplica cuando la forma es inequívoca.
Leerlo tú mismo
Puedes seguir cada paso de esto en Glass. Abre el ensamblado, selecciona el miembro y alterna entre la vista de C# descompilado y la vista de IL para ver el literal u8 por un lado y el par ldsflda / newobj por el otro. Luego abre la navegación de metadatos, salta a <PrivateImplementationDetails> e inspecciona el campo __StaticArrayInitTypeSize para ver los bytes .data crudos —incluido el 00 final que tu span nunca alcanza—. Es la forma más clara de observar cómo un único token del código fuente se reparte por tres partes distintas de un archivo PE.
Glass.NET es un descompilador de .NET gratuito para inspeccionar código que tienes derecho a inspeccionar: tus propios ensamblados, tus dependencias y binarios de terceros que tienes licencia para analizar. Reconstruir fielmente construcciones como los literales u8 es la diferencia entre leer tu código y leer la contabilidad del compilador.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.