deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

Inside .NET 8's UnsafeAccessor: how the JIT binds private members without reflection

A dev.to deep dive decompiles .NET 8's UnsafeAccessor, showing how static extern declarations get bound to private members at JIT time, with the target name living only inside an attribute blob.

Inside .NET 8's UnsafeAccessor: how the JIT binds private members without reflection

A deep dive on dev.to picks apart [UnsafeAccessor], the .NET 8 feature that gives code a supported route to private members without reflection — and shows that everything which makes an accessor work lives in metadata, not in instructions.

Access without the reflection overhead

According to the post, the pattern is a static extern method decorated with UnsafeAccessorAttribute, carrying the kind of access wanted (field, method, constructor, or their static counterparts) and, where relevant, the target member's name. The runtime resolves the declaration against the real member at JIT time and supplies the body itself. The payoff is that the call site becomes a plain direct call: no MethodInfo object, no delegate and no boxing. The author notes that serializers, test harnesses and parts of the base class library itself rely on the mechanism to reach internals they are entitled to touch.

A method with no body

What makes the feature odd to decompile is that the compiler emits nothing for the accessor. Its RVA is zero, and the IL contains no instructions, no locals and no maxstack. The post compares this to P/Invoke, where the runtime likewise completes a bodyless managed method, but points to the distinguishing detail: an UnsafeAccessor method carries no pinvokeimpl flag and no ImplMap metadata row, which separates it from DllImport, the other common kind of bodyless extern.

The private name hides in the attribute blob

The post then decodes the custom-attribute blob byte by byte, because that is the only place the private member's name exists. After a two-byte prolog comes the UnsafeAccessorKind constructor argument, serialized as its underlying integer — 3 for Field, 1 for Method, 0 for Constructor — followed by the count of named arguments, a marker indicating a property of string type, the property name "Name", and finally the member name as a length-prefixed UTF-8 string such as "_secret". Crucially, the post states that no MemberRef or FieldRef token anywhere in the module points at the target member. Binding is done by string, resolved by the JIT against the type of the accessor's first parameter.

Three shapes, three conventions

Each accessor kind has its own signature rules, per the post:

  • Field accessors return a managed byref — ref string in C# is string& in IL — which is what lets callers both read and write the field. A decompiler that drops the byref and renders a plain string produces an accessor that returns a copy and no longer aliases the field.
  • Method accessors take the target instance as their first parameter, with the remaining parameters matching the private method's signature exactly. A call in IL references only the accessor, never the private method.
  • Constructor accessors invert the shape: no instance parameter, the return type names the type being constructed, and Name is not used at all — the runtime matches the parameter list against that type. The blob holds just the kind and a zero count of named arguments. Emitting a Name for a constructor accessor would be wrong, the post warns, and some runtimes reject it.

StaticMethod and StaticField variants round out the set by dropping the instance parameter, since the target is static.

Decompilers have to reconstruct the intent

The post walks through how the Glass.NET decompiler handles each case: it reads the kind out of the blob, restores the Name where one is present, and regenerates the original static extern declaration. That reconstruction must be exact — parameter and return types have to be preserved precisely, or the runtime binding would fail on recompile. And because the member-reference table and the instruction stream both show the accessor referencing nothing, the member name can only be recovered from the serialized blob.

Why it matters

UnsafeAccessor represents a shift in how .NET code is expected to reach internals: a compile-time-declared, JIT-resolved, direct-call path instead of runtime reflection, with the performance characteristics that implies. The dev.to analysis shows the mechanism is also a metadata contract that is nearly invisible to tooling built on instruction streams. Decompilers, static analyzers and IL weavers all need to learn to read the attribute blob to understand what these methods actually do, and details like the byref return on field accessors or the deliberately unused Name on constructors are exactly where naive tooling breaks.

  • #dotnet
  • #jit
  • #decompilation
  • #reflection
  • #il-metadata