Skip to content

Unsafe Code, Pointers & fixed

Key Points

  • unsafe enables raw pointers (int*), pointer arithmetic, and the fixed statement. Disables type/memory safety in that scope.
  • fixed pins a managed object so the GC can't move it — required to take an address from a managed array, string, or struct.
  • Modern .NET rarely needs unsafeSpan<T>, Memory<T>, ref struct, Vector<T> cover most perf needs without disabling safety.
  • Real use cases: P/Invoke buffer fixing, intrinsics that need pointers, parsing binary protocols where bounds checks are proven redundant, very tight numerical kernels.
  • Senior rule: try Span<T> first. If you need unsafe, justify it in a comment with a benchmark, isolate it behind a safe API, and test with overflow scenarios.

Concepts (deep dive)

Enabling unsafe

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
public unsafe class FastBuffer
{
    public void Fill(byte* dst, int count, byte value)
    {
        for (int i = 0; i < count; i++) dst[i] = value;
    }
}

The unsafe keyword applies to types, members, or blocks.

Pointer types

int* p;          // pointer to int
void* v;         // pointer to anything
int** pp;        // pointer to pointer
int*[] arr;      // array of pointers
delegate* unmanaged[Cdecl]<int, int, int> fp;  // function pointer (C# 9+)

Pointer arithmetic: p + 1 advances by sizeof(T) bytes (just like C). Unsafe.Add(ref T, int) does the same for managed refs.

fixed for arrays and strings

public unsafe int Sum(int[] data)
{
    int total = 0;
    fixed (int* p = data)               // pin
    {
        for (int i = 0; i < data.Length; i++) total += p[i];
    }                                    // unpin
    return total;
}

The pinned address is valid only inside the block. After the block, GC may move the array.

fixed on a struct field exposes the address of the field directly — used in interop with structs containing fixed-size buffers:

public unsafe struct PacketHeader
{
    public fixed byte Magic[4];          // inline 4-byte buffer in the struct
}

stackalloc

Allocate on the stack — zero GC pressure, but limited size (~1 MB):

unsafe int Parse(ReadOnlySpan<char> s)
{
    int* digits = stackalloc int[s.Length];
    // ...
}

Modern equivalent without unsafe:

Span<int> digits = stackalloc int[s.Length];     // safe Span over stack memory

Span<int> from stackalloc is the senior default; raw pointers only when you must.

ref and ref struct instead of pointers

Span<T> is a ref struct — it carries a ref T and a length. Combined with Unsafe.Add, you have pointer-equivalent indexing without unsafe:

public static int Sum(ReadOnlySpan<int> data)
{
    ref int p = ref MemoryMarshal.GetReference(data);
    int total = 0;
    for (int i = 0; i < data.Length; i++) total += Unsafe.Add(ref p, i);
    return total;
}

Identical codegen to the pointer version on modern JIT. No AllowUnsafeBlocks. Safer.

Unsafe static class

System.Runtime.CompilerServices.Unsafe is the safe-but-careful API:

ref T r = ref Unsafe.AsRef<T>(ptr);
T value = Unsafe.Read<T>(ptr);
Unsafe.Write(ptr, value);
nint offset = Unsafe.ByteOffset(ref a, ref b);
ref U u = ref Unsafe.As<T, U>(ref t);   // reinterpret cast

Available without unsafe keyword. Doesn't bypass GC pinning rules — you still need fixed for managed objects.

Pinning via GCHandle

For pointers that must survive across method calls or async boundaries:

var bytes = new byte[1024];
var h = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try { var ptr = h.AddrOfPinnedObject(); /* ... */ }
finally { h.Free(); }

GCHandle survives until Free(); fixed only spans a block.

Pointer arithmetic vs Span<T> slicing

// unsafe
fixed (byte* p = buffer) { ProcessHeader(p); ProcessBody(p + 16); }

// safe
ProcessHeader(buffer.AsSpan(0, 16));
ProcessBody(buffer.AsSpan(16));

Slicing produces another Span<T> with no allocation — generally same codegen.

Function pointers

[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static int Square(int x) => x * x;

unsafe static void Main()
{
    delegate* unmanaged[Cdecl]<int, int> fp = &Square;
    Console.WriteLine(fp(7));        // 49
}

No delegate allocation; matches C ABI directly. Critical for AOT scenarios that pass callbacks to native code.


Code: correct vs wrong

❌ Wrong: address escaping a fixed block

unsafe int* Leak(int[] arr)
{
    fixed (int* p = arr) { return p; }   // pointer is dangling after block exits
}

✅ Correct: keep usage inside the fixed scope, or use GCHandle

unsafe void UseInside(int[] arr) { fixed (int* p = arr) { /* use here */ } }

❌ Wrong: unsafe when Span<T> would do

public unsafe int Sum(int[] data)
{
    int total = 0;
    fixed (int* p = data)
        for (int i = 0; i < data.Length; i++) total += p[i];
    return total;
}

✅ Correct: safe equivalent with same codegen

public int Sum(ReadOnlySpan<int> data)
{
    int total = 0;
    foreach (var v in data) total += v;
    return total;
}

❌ Wrong: stackalloc of unbounded size

unsafe void Parse(int n) { byte* p = stackalloc byte[n]; /* StackOverflowException for large n */ }

✅ Correct: bound and fall back to heap

const int MaxStack = 1024;
Span<byte> buf = n <= MaxStack ? stackalloc byte[n] : new byte[n];

Design patterns for this topic

Pattern 1 — Span<T> first, unsafe only when proved

public static int CountByte(ReadOnlySpan<byte> data, byte target)
{
    int count = 0;
    foreach (var b in data) if (b == target) count++;
    return count;
}

Benchmark with BenchmarkDotNet before reaching for pointers — modern JIT auto-vectorizes many spans.

Pattern 2 — Safe API around unsafe internals

public static int FastSum(ReadOnlySpan<int> data)
{
    if (data.IsEmpty) return 0;
    return SumUnsafe(data);
}

private static unsafe int SumUnsafe(ReadOnlySpan<int> data)
{
    fixed (int* p = data)
    {
        int total = 0;
        for (int i = 0; i < data.Length; i++) total += p[i];
        return total;
    }
}

Caller never sees the pointer; testable and reviewable.

Pattern 3 — Hardware intrinsics over raw pointers

if (Avx2.IsSupported && data.Length >= Vector256<int>.Count)
{
    // SIMD via Vector256<T> + intrinsics — typically pointers required
}

See Runtime & CLR hardware-intrinsics topic.

Pattern 4 — Fixed-size struct buffers for binary layout

[StructLayout(LayoutKind.Sequential, Size = 64)]
public unsafe struct PacketHeader
{
    public fixed byte Bytes[64];
}

For protocol headers where exact size and layout are required.


Pros & cons / trade-offs

Approach Pros Cons
Span<T> Safe, fast, allocator-free Lifetime restricted (ref struct)
Unsafe.As/Add No unsafe keyword, fast Easy to misuse
fixed + pointers Maximum control, interop Disables safety, AOT considerations
stackalloc (Span) Zero GC Stack-bounded
GCHandle.Alloc(Pinned) Long-lived pinning Heap fragmentation if held long

When to use / when to avoid

  • Avoid unsafe for app code — Span<T> solves nearly everything.
  • Use it in a few low-level libraries (parsers, codecs, intrinsics, P/Invoke buffers).
  • Always keep the unsafe surface tiny and behind a safe public API.
  • Profile firstunsafe rarely beats Span<T> on modern JIT; complexity isn't free.

Interview Q&A

Q1. Why does fixed exist? A. The GC compacts heap objects during collection, moving them to new addresses. A pointer obtained without pinning becomes stale. fixed tells the GC not to move the target for the block's duration.

Q2. Difference between fixed and GCHandle.Alloc(Pinned)? A. fixed pins for a syntactic block; GCHandle pins until Free(), surviving across method calls. GCHandle is for long-lived pinning (e.g., a buffer registered with native code that retains the pointer).

Q3. Why is Span<T> a ref struct? A. To prevent it escaping the stack — the GC tracks managed references inside the struct, but the runtime can't guarantee validity if the span is captured into a heap object that outlives the original buffer.

Q4. What's Unsafe.As<TFrom, TTo>(ref TFrom)? A. A reinterpret cast on a managed reference — same memory, different view. Used to cast between layout-compatible structs without copying. Equivalent to (*(TTo*)&from) but without unsafe.

Q5. Why might unsafe be slower than Span<T>? A. The JIT removes bounds checks on Span<T> when it can prove safety; with raw pointers it can't always optimize as aggressively. Plus, intrinsics often expect Vector<T> over a Span<T> directly.

Q6. What's the difference between int* and ref int? A. int* is unmanaged, escapes lifetime tracking, needs fixed over managed memory. ref int is GC-aware — the runtime tracks it and updates if the target moves.

Q7. Can you use unsafe with NativeAOT? A. Yes — unsafe and pointers are supported. The trim/AOT analyzer warns on unsafe patterns it can't prove correct. Function pointers (delegate*) work fine.

Q8. What does fixed (byte* p = &header.Bytes[0]) do for a struct? A. Pins the struct in place and produces a pointer to the field. Required if header lives on the heap (e.g., it's a field of a class).


Gotchas / common mistakes

  • Returning a pointer from a fixed block — instant undefined behavior.
  • Using stackalloc of unbounded size — stack overflow, no exception.
  • Forgetting unsafe doesn't pin — int* p = &arr[0] outside fixed is wrong.
  • Mixing Span<T> with cross-async boundaries — ref struct can't live in async state machines (compiler error).
  • GCHandle.Alloc without matching Free — pinned memory leaks and fragments the heap.
  • Overlapping Span and pointer access to the same buffer — aliasing rules.
  • Believing unsafe is always faster — modern JIT auto-vectorizes; benchmark.

Further reading