Unsafe Code, Pointers and Function Pointers
Objective
C# can drop below the managed world: pointers, pinning, native memory and
function pointers compiled to a bare calli, with no bounds checks, type checks
or GC help. Since Span<T>, MemoryMarshal and the vectorized helpers
arrived, most code that once needed this no longer does. This concept covers
enabling unsafe on .NET 10 / C# 14, the pointer operators, fixed and
fixed-size buffers next to [InlineArray], Unsafe and NativeMemory, what
an overrun and a stale pointer really do, function pointers with measured call
costs, and the safety model that is in preview in C# 15.
Use Cases
- Interop: native buffers,
qsort-style callbacks, native function tables. - Hot parsing or hashing loops where a profiler proved bounds checks matter.
- Reading code built on
Unsafe.As,Unsafe.AddorMemoryMarshal. - Reviewing a pull request that turns on
AllowUnsafeBlocks.
Deep Dive
Turning it on
Without the switch, any unsafe block fails with error CS0227: Unsafe code may only appear if compiling with /unsafe. The project opts in with
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>; the keyword then marks a block, a
method or a type. A pointer outside that context is CS0214, sizeof of a user
struct CS0233 (sizeof(int) is a constant, legal anywhere). The switch alone
marks the module UnverifiableCodeAttribute, even with no unsafe code in it.
Pointer operators and scaled arithmetic
& takes an address, * dereferences, -> reaches a member, p[i] is
*(p + i), and arithmetic moves by sizeof(T):
csharpint n = 0x11223344;
int* pi = &n;
byte* pb = (byte*)pi;
Console.WriteLine($"{*pi:X} {pb[0]:X} {pb[3]:X}"); // 11223344 44 11 (little-endian)
long* pl = (long*)pi;
Console.WriteLine($"{(nint)(pi + 1) - (nint)pi} {(nint)(pl + 1) - (nint)pl}"); // 4 8
int* stack = stackalloc int[4] { 10, 20, 30, 40 }; int* end = stack + 4;
Console.WriteLine($"{end - stack} {stack[2]} {*(stack + 3)}"); // 4 30 40
Point* pp = &pt; pp->Y = 5; // pt.Y is 5&n works because a local is a fixed variable in the docs' terms. An array
element or a class field can move, so &arr[0] is CS0212 ("You can only take
the address of an unfixed expression inside of a fixed statement initializer").
Managed types are softer on C# 14: string* ps = &s; only warns with CS8500
("This takes the address of, gets the size of, or declares a pointer to a
managed type ('string')") and points at the local slot holding the reference,
not at the characters. stackalloc string[2] is still the error CS0208.
fixed, fixed-size buffers and [InlineArray]
fixed pins an object for the block so the GC cannot move it: arrays,
strings, spans (via GetPinnableReference) and fixed-size buffers:
csharpfixed (int* pa = arr) { pa[1] = 20; } // arr is 1,20,3,4
fixed (char* pc = "héllo") Console.WriteLine($"{pc[1]} {(int)pc[5]}"); // é 0
fixed (byte* ph = heap.AsSpan()) { ph[0] = 9; } // heap[0] is 9
fixed (int* pe = Array.Empty<int>()) Console.WriteLine(pe == null); // TrueA pinned string exposes its terminating '\0'; an empty array pins to null.
A fixed-size buffer embeds elements in a struct, as C layouts need, and
[InlineArray] (C# 12) gives the same layout in safe code:
csharpunsafe struct Header { public fixed byte Magic[16]; public int Version; }
[InlineArray(16)] struct Bytes16 { private byte _e0; }
var h = new Header { Version = 0x42 }; int i = 16;
Console.WriteLine($"{sizeof(Header)} {h.Magic[i]:X}"); // 20 42: index 16 read Version
var ia = new Bytes16(); Span<byte> view = ia; // 16 elements, no unsafe
ia[i] = 1; // IndexOutOfRangeExceptionFixed-buffer access is pointer access, so index 16 silently returned the next
field; inside a class, using the buffer unpinned is CS1666. stackalloc has
the same split: into a Span<int> it is safe and buf[3] threw
IndexOutOfRangeException; into an int* it needs unsafe, and the docs note
that pointer element access "doesn't check for out-of-bounds errors".
Unsafe, MemoryMarshal and NativeMemory
Unsafe works on managed references (ref T), so most of it compiles without
the unsafe keyword (Unsafe.SizeOf<Header>() is 20). Its docs warn that most
APIs "do not perform any argument checking or validation":
csharpUnsafe.As<float, int>(ref f); // 3F800000 for 1.0f
ref int r0 = ref MemoryMarshal.GetArrayDataReference(nums); // nums = 5,6,7,8
Unsafe.Add(ref r0, 2); // 7, no bounds check
MemoryMarshal.GetReference(nums.AsSpan(1)); // 6
Unsafe.ReadUnaligned<int>(ref raw[1]); // 1 for bytes FF 01 00 00 00
string s = Unsafe.As<string>(new object()); // no cast check: s.GetType() is ObjectNativeMemory wraps the C allocator (the docs name malloc, calloc,
aligned_alloc and free). The GC never sees that memory, so each block needs
its Free, and AlignedAlloc(1024, 64) (which returned an address with
% 64 == 0) needs AlignedFree. Wrapping the pointer at once in
new Span<byte>(p, 16) brings the checks back: span[16] threw
IndexOutOfRangeException.
What goes wrong: overruns and stale pointers
Reading 11 ints through a pointer to a pinned 4-int array raised nothing and
walked into the next array: its header (0), its type handle (matching
typeof(int[]).TypeHandle.Value), its length 2, padding and its first
element 111. The array indexer refuses:
textp4[0..10]: 1 2 3 4 0 0 159007776 1 2 0 111 four[4]: IndexOutOfRangeException: Index was outside the bounds of the array.
A pointer taken without pinning goes stale when the GC compacts. With 1,000
dead arrays allocated before data = { 11, 22, 33, 44 }, the address came from
Unsafe.AsPointer(ref data[0]) and then
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true) ran:
textbefore: 11C6355C0 gen 0 read 11,22 after: 11C633588 gen 1 moved True old pointer reads 0,0; array holds 11,22 pinned: moved False, gen 0, reads 11
All six runs (Release and Debug) moved the array; inside fixed the address
held. Only reads were made: a write through the old pointer would hit memory
the GC can reuse. The docs' rule: pin "for as long as the pointer is used".
Function pointers and what a call costs
delegate*<int, int, int> holds the address of a static method and is called
with calli, with no delegate object. managed is the default convention
(add == same is True); unmanaged[...] targets native code:
csharpdelegate*<int, int, int> add = &Ops.Add; // add(2, 3) is 5
delegate* managed<int, int, int> same = &Ops.Add;
nint libc = NativeLibrary.Load("/usr/lib/libSystem.B.dylib");
var abs = (delegate* unmanaged[Cdecl]<int, int>)NativeLibrary.GetExport(libc, "abs"); // abs(-42) is 42
var qsort = (delegate* unmanaged[Cdecl]<void*, nuint, nuint, delegate* unmanaged[Cdecl]<void*, void*, int>, void>)
NativeLibrary.GetExport(libc, "qsort");
int[] values = { 42, 7, 19, 3, 25 };
fixed (int* pv = values) qsort(pv, (nuint)values.Length, sizeof(int), &Ops.Compare);
// values: 3,7,19,25,42
static unsafe class Ops
{
public static int Add(int a, int b) => a + b;
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static int Compare(void* a, void* b) => (*(int*)a).CompareTo(*(int*)b);
}Native qsort called back into C# through [UnmanagedCallersOnly]. Taking
&obj.Method is CS8759, calling Compare directly CS8901, converting it to a
Func CS8902, storing it in a managed delegate* CS8786. Measured with
BenchmarkDotNet 0.15.8, .NET 10.0.0, Apple M4 Max (arm64), 1,000 dependent
calls per operation, targets NoInlining unless stated:
| Call | Per call |
|---|---|
| direct: inlined / not inlined | about 0.23 / 1.16 ns |
| function pointer / delegate | about 1.19 / 1.26 ns |
| interface (sealed class) | about 1.07 ns |
| function pointer / delegate to an inlinable method | about 1.12 / 1.18 ns |
native abs: unmanaged[Cdecl] / plus SuppressGCTransition |
about 1.00 / 0.76 ns |
The docs call calli "more efficient" than a delegate's Invoke; it was, by
about 5%, and the interface call beat both. The real gap is inlining: even
aimed at an inlinable method, function pointers and delegates cost five times
an inlined call. SuppressGCTransition saved about a quarter on a trivial
native call; its docs allow it only for code that runs under a microsecond and
never blocks, throws or calls back, and list "GC starvation" and "Data
corruption" among the consequences of misuse.
Verifiability and the safe replacements
The docs call unsafe code unverifiable, "code whose safety can't be verified
by .NET tools". In .NET Framework, security-transparent code could not
"contain unsafe or unverifiable code"; the porting docs say code access
security and security transparency are not supported as a boundary on .NET 6+.
Nothing on .NET 10 refused to load the code above: the gate is the compiler
switch.
Most former reasons for pointers have a safe API now: spans and stackalloc
into a span, Buffer, MemoryMarshal, BinaryPrimitives, SearchValues<T>
and Vector<T>, often faster because they are vectorized. Counting '\n' in
1 MiB of random bytes with the same benchmark setup:
csharpfixed (byte* p = data) // before: about 294 us
for (byte* cur = p, end = p + data.Length; cur < end; cur++)
if (*cur == (byte)'\n') count++;
int count = data.AsSpan().Count((byte)'\n'); // after: about 53 usWhere the language is going: a preview in C# 15 and .NET 11
The unsafe-code page now describes two models. The original one (the default,
and the only one SDK 10.0.100 has) makes pointer features unsafe and puts no
duty on callers of an unsafe member. The updated memory safety model, which
the docs say "is in preview in C# 15 and .NET 11", makes declaring pointers,
&, fixed, stackalloc to a pointer and sizeof legal in safe code, while
*p, ->, p[i], function pointer calls and fixed-buffer indexing still need
unsafe. An unsafe member becomes requires-unsafe: callers need an
unsafe context, so the audit duty propagates until a method validates its
inputs behind a safe signature. It is still a preview in the released .NET 11, with two
separate opt-ins. On SDK 11.0.100, LangVersion preview alone enabled the
relaxations: int* p = &n; and a fixed statement compiled in safe code
without AllowUnsafeBlocks, and *p was error CS9360 ("This operation may only
be used in an unsafe context"); with the default C# 15 the same pointer is
still CS0214. Adding
<Features>$(Features);updated-memory-safety-rules</Features> enabled the
caller rules: calling an unsafe method from safe code was CS9362, an extern
method had to be marked unsafe or safe (CS9389), and the compiled module
carried MemorySafetyRulesAttribute(2). The details can still change.
Trade-offs
- Speed versus guarantees. Pointers drop bounds checks, type checks and GC
tracking at once. Measure first: the safe
Countwas about 5.5 times faster. - Fixed buffers versus inline arrays. Fixed buffers match C layouts but are
unchecked. Prefer
[InlineArray]unless a native signature needs one. - Function pointers versus delegates. No allocation and a slightly cheaper call, but static targets only, no closures and no inlining. They pay off for native callbacks and dispatch tables more than for speed.