DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling UTF-8 string literals: the RVA blob behind "..."u8

A "abc"u8 literal looks like the smallest possible feature: take a string, stick a u8 on the end, get its UTF-8 bytes. But the thing you get back is neither a string nor a byte[]. It is a ReadOnlySpan<byte>, and the bytes it spans do not live on the heap at all — they are baked into the assembly as a static data blob and the span points straight at them. That design is what makes u8 literals allocation-free, and it is also what makes them interesting to decompile: the pattern on disk looks nothing like the source, and a decompiler that reads it literally produces something unrecognisable. This post walks the full lowering and shows what Glass.NET reconstructs.

What the source looks like

UTF-8 string literals arrived in C# 11. The point of them is to hand UTF-8 bytes to the many APIs that want ReadOnlySpan<byte> — Utf8Formatter, Utf8Parser, sockets, pipelines, JSON writers — without paying for a string and a runtime Encoding.UTF8.GetBytes call on every use.

using System;

static class Protocol
{
    public static ReadOnlySpan<byte> Preamble => "abc"u8;
}
Enter fullscreen mode Exit fullscreen mode

The property returns a span. There is no field of type string, no byte[], and no encoding work at run time. Everything was settled at compile time.

What the compiler really emits

Two things get written into the assembly. First, a static field holding the encoded bytes. Second, the IL that constructs a span over that field. Here is the method body, as Glass shows it in the IL view:

.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
}
Enter fullscreen mode Exit fullscreen mode

Three instructions. ldsflda pushes the address of a static field — not its value, its address, as a void*. ldc.i4.3 pushes the length. newobj calls the unsafe ReadOnlySpan<byte> constructor that takes a pointer and a length. The span is now a window onto the field's bytes, and those bytes never move.

The field and its RVA blob

The field the address came from lives in the compiler-generated <PrivateImplementationDetails> type — the same hidden holding pen where array initialisers and other constant blobs go. Its type is a nested value type named for its own size:

.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)
Enter fullscreen mode Exit fullscreen mode

The field has no initial value in the usual sense; it has an RVA — a relative virtual address — pointing into the assembly's raw data section, declared here by .data. The blob is 61 62 63 00: a, b, c, and a trailing 00. The field name is derived from a content hash, which is why two identical u8 literals anywhere in the assembly share one field.

The hidden null terminator

Look again at the two numbers that disagree. The field's type is __StaticArrayInitTypeSize=4 and the blob is four bytes, but the length pushed before the constructor is 3. Roslyn appends a single 0x00 to every u8 payload and sizes the field to include it, yet constructs the span over only the real bytes. So "abc"u8 is physically four bytes on disk and logically a span of Length 3.

That terminator is deliberate. It lets the embedded bytes double as a C-style NUL-terminated string for native interop that expects one, at the cost of a single byte. Your span, though, stops at index 2 — the 00 is backing store the span's bounds never reach. Getting this relationship right is exactly the kind of detail a decompiler has to encode: the reconstructed literal must contain the payload bytes and not the terminator.

The pipeline, end to end


How "abc"u8 compiles to an RVA data field and a ReadOnlySpan over it, and how Glass.NET reconstructs the literal


1 · Source
"abc"u8
UTF-8 encoded at compile time

2 · RVA data field
61 62 63 00
in <PrivateImplementationDetails>

3 · IL at use site
ldsflda field
ldc.i4.3
newobj ROSpan..ctor



Glass.NET reconstructs
ReadOnlySpan<byte> x = "abc"u8;
length 3 = blob size 4 minus the trailing 00 terminator

The compiler encodes, stores, and references; a decompiler has to run the same three stages in reverse and recognise that the whole shape is one literal.

What a naive decompiler shows — and what Glass shows

Nothing in the three instructions says "UTF-8 literal." A decompiler that reconstructs the IL literally has a ReadOnlySpan<byte> built from the address of an opaque field, and the most honest thing it can print is exactly that:

// literal reconstruction — technically correct, unreadable
public static ReadOnlySpan<byte> Preamble =>
    new ReadOnlySpan<byte>(
        Unsafe.AsPointer(ref <PrivateImplementationDetails>.2E7D2C...A25AEFC6),
        3);
Enter fullscreen mode Exit fullscreen mode

That compiles in spirit but tells you nothing, and it leaks a generated type that was never in your source. Glass matches the pattern instead. When it sees ldsflda onto a __StaticArrayInitTypeSize field with RVA data, feeding a ReadOnlySpan<byte>..ctor(void*, int32) whose length is the blob size minus a single trailing 0x00, and the bytes decode as valid UTF-8, it rebuilds the literal:

static class Protocol
{
    public static ReadOnlySpan<byte> Preamble => "abc"u8;
}
Enter fullscreen mode Exit fullscreen mode

If the bytes are not valid UTF-8, or the length does not line up with the terminator convention, Glass does not guess — it falls back to showing the span over a byte blob, because that is what the assembly actually means. The reconstruction is only applied when the shape is unambiguous.

Reading it yourself

You can follow every step of this in Glass. Open the assembly, select the member, and switch between the decompiled C# view and the IL view to see the u8 literal on one side and the ldsflda / newobj pair on the other. Then open metadata navigation, jump to <PrivateImplementationDetails>, and inspect the __StaticArrayInitTypeSize field to see the raw .data bytes — including the trailing 00 that your span never reaches. It is the clearest way to watch a single source token spread across three different parts of a PE file.

Glass.NET is a free .NET decompiler for inspecting code you have the right to inspect — your own assemblies, your dependencies, and third-party binaries you are licensed to analyse. Reconstructing constructs like u8 literals faithfully is the difference between reading your code and reading the compiler's bookkeeping.

Top comments (0)