Unsafe.AsPointer and blittable value types passed by ref

Summary

A developer proposed using Unsafe.AsPointer<T> to obtain a pointer to a ref readonly parameter of a blittable value type (where T : unmanaged) to copy data to a native buffer. They assumed this is safe because the value is blittable and lives on the stack, meaning the GC won’t relocate it. This assumption is dangerously incorrect. While Unsafe.AsPointer<T> might work in simple Release builds, it relies on undefined behavior and fails in Debug builds or specific execution contexts. The correct approach involves either using fixed statements or, preferably, modern C# features like Span<T> and MemoryMarshal to avoid unsafe code altogether.

Root Cause

The root cause of the potential failure is the behavior of the JIT compiler, specifically regarding hidden method transitions and debug-mode stack marshalling.

When you use Unsafe.AsPointer<T>(in value), you are asking for the memory address of the parameter. While T is unmanaged (blittable), the runtime makes no guarantee that a ref parameter passed across a method call boundary (like an extern call or even internal runtime calls) will not be a managed pointer pointing to a location on the stack that requires a guard or copy.

In Debug builds, the JIT often creates a copy of the ref parameter on the stack or places a GC guard around it, causing Unsafe.AsPointer to return a pointer to this temporary location or a protected address. Once the method returns or the guard is removed, that pointer becomes invalid or accessing it triggers an assertion/crash.

Furthermore, relying on Unsafe.AsPointer bypasses the runtime’s safety mechanisms. It is an “unsafe” operation for a reason: it promises that the memory won’t move, but if the runtime determines it must move or guard the data for the call to be valid, the contract is broken.

Why This Happens in Real Systems

This behavior stems from the memory model of the .NET runtime and the distinction between managed and unmanaged pointers.

  • GC Barriers: Even for unmanaged types, passing by ref creates a managed pointer. The GC tracks these pointers. If a GC occurs, the target of the pointer might move, requiring the pointer itself to be updated. Unsafe.AsPointer strips this tracking.
  • Debug vs Release JIT Differences: Debug builds often disable optimizations like Enregistration (keeping variables in CPU registers) and may explicitly spill values to the stack with guards to allow for inspectability. Unsafe.AsPointer in Debug mode might point to a stack location that isn’t “pinned” or is temporary.
  • in Parameter Semantics: The in keyword is a ref parameter with readonly intent. The compiler and runtime may still pass this by reference (pointer to pointer) depending on the ABI (Application Binary Interface), specifically “Health” or “Write” semantics. You aren’t taking the address of the value; you are taking the address of the pointer to the value if you aren’t careful.

Real-World Impact

Using Unsafe.AsPointer in this context leads to:

  • Debug Build Crashes: The code will likely crash immediately in Debug builds due to System.AccessViolationException or runtime assertion failures.
  • Silent Memory Corruption: In Release builds, it might appear to work, but if the GC triggers during the operation or if the stack is rearranged, you could write to the wrong memory address, corrupting the heap or stack.
  • Maintenance Nightmares: Future refactoring (e.g., adding logging, changing method inlining hints) can change the JIT’s decision on where to place the parameter, breaking the code unexpectedly.
  • Security Vulnerabilities: Writing to an incorrect memory address is a classic buffer overflow/vector, potentially exploitable.

Example or Code

Here is the problematic approach and the correct alternatives.

using System;
using System.Runtime.CompilerServices;

public unsafe class Buffer
{
    public byte* Pointer;
}

public static unsafe class UnsafeWriter
{
    // DANGEROUS: Relies on undefined JIT behavior.
    // May fail in Debug or when GC/Stack guards are active.
    public static void WriteBad(Buffer buffer, ref readonly T value, int offset) where T : unmanaged
    {
        // This pointer might point to a temporary copy or guarded slot
        // depending on the runtime state.
        byte* valuePtr = (byte*)Unsafe.AsPointer(ref value);

        // This is essentially a memcpy
        Buffer.MemoryCopy(valuePtr, buffer.Pointer + offset, sizeof(T), sizeof(T));
    }

    // ACCEPTABLE: The 'fixed' statement pins the value.
    public static void WriteFixed(Buffer buffer, ref readonly T value, int offset) where T : unmanaged
    {
        fixed (T* valuePtr = &value)
        {
            Buffer.MemoryCopy((byte*)valuePtr, buffer.Pointer + offset, sizeof(T), sizeof(T));
        }
    }

    // PREFERRED: Zero-copy, type-safe, high performance via Spans.
    public static void WriteSpan(Buffer buffer, ref readonly T value, int offset) where T : unmanaged
    {
        // Create a Span over the value
        ReadOnlySpan data = MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref value), sizeof(T));

        // Create a Span over the destination
        Span destination = new Span(buffer.Pointer + offset, sizeof(T));

        data.CopyTo(destination);
    }
}

How Senior Engineers Fix It

Senior engineers prioritize safety and correctness over micro-optimizations unless proven necessary by profiling.

  1. Use Span<T> (Primary Recommendation):
    Instead of pointers, cast the reference to a ReadOnlySpan<byte>. This allows the runtime to handle the memory safely while remaining zero-overhead.

    // Conceptual logic
    ReadOnlySpan source = MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref value), sizeof(T));

    This is robust, works everywhere, and the JIT optimizes it to the same assembly as pointers.

  2. Use fixed statements (Secondary):
    If you absolutely must work with pointers, the fixed statement is the standard. It tells the GC “do not move this value.” It is explicit, safe, and handles the stack pinning correctly for ref parameters.

  3. Avoid Unsafe.AsPointer for Method Parameters:
    Reserve Unsafe.AsPointer only for when you are interacting with unmanaged memory APIs that strictly require a raw pointer and you have already pinned the memory via fixed or GCHandle. Never use it as a shortcut to avoid the fixed keyword.

Why Juniors Miss It

Junior developers often misunderstand the abstraction of pointers in C#.

  • “Value Types are Stack Only”: They learn that value types are on the stack and assume that means they are “static” memory locations. They miss that the stack itself is a managed resource with guards and transitions.
  • Documentation Surface Level: The documentation for Unsafe.AsPointer states it “returns a pointer to the given value.” It does not explicitly warn about “Debug builds” or “Call conventions” in giant red text, leading them to believe it is a direct equivalent to the C++ & operator in all contexts.
  • Fear of fixed: The fixed keyword looks complex to beginners (it introduces a new scope), so they look for a “cleaner” one-liner like Unsafe.AsPointer.
  • Confusion of ref vs Pointer: They treat ref T as T*. While conceptually similar, ref is a managed reference; Unsafe.AsPointer strips the management. They assume because T is unmanaged, the management doesn’t matter. It does.