Unsafe Code, Pointers & fixed
Key Points
unsafeenables raw pointers (int*), pointer arithmetic, and thefixedstatement. Disables type/memory safety in that scope.fixedpins 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
unsafe—Span<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 needunsafe, justify it in a comment with a benchmark, isolate it behind a safe API, and test with overflow scenarios.
Concepts (deep dive)
Enabling unsafe
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):
Modern equivalent without unsafe:
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 = □
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
❌ 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
✅ Correct: bound and fall back to heap
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
unsafefor 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 first —
unsaferarely beatsSpan<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
fixedblock — instant undefined behavior. - Using
stackallocof unbounded size — stack overflow, no exception. - Forgetting
unsafedoesn't pin —int* p = &arr[0]outsidefixedis wrong. - Mixing
Span<T>with cross-async boundaries —ref structcan't live in async state machines (compiler error). GCHandle.Allocwithout matchingFree— pinned memory leaks and fragments the heap.- Overlapping
Spanand pointer access to the same buffer — aliasing rules. - Believing
unsafeis always faster — modern JIT auto-vectorizes; benchmark.