When You Need Direct Memory Access
It's tempting to use unsafe code for any performance-sensitive operation. It works—until you hit buffer overruns, memory corruption, or crashes that safe C# would have prevented. Unsafe code trades the safety net of managed memory for raw performance and low-level control.
The unsafe keyword lets you use pointers, perform pointer arithmetic, and access memory directly without garbage collector oversight. This becomes necessary when interfacing with native libraries that expect pointers, when optimizing critical hot paths where bounds checking hurts performance, or when implementing low-level data structures like memory pools. Modern .NET provides safe alternatives like Span<T> that often match unsafe performance while maintaining safety guarantees.
You'll learn when unsafe code truly makes sense, how to write it without introducing vulnerabilities, and how to benchmark whether unsafe operations actually improve your specific scenario. We'll build examples that show proper unsafe patterns and demonstrate the safety mechanisms you must maintain manually.
Unsafe Code Fundamentals
Unsafe code requires explicit opt-in at both the project level and code level. You must enable unsafe blocks in your project file and mark specific methods, types, or statement blocks with the unsafe keyword. This double opt-in ensures you're consciously choosing to bypass C#'s safety mechanisms.
Pointers in C# work similarly to C or C++ pointers. You can take the address of a variable using the ampersand operator, dereference pointers with the asterisk, and perform pointer arithmetic. The fixed statement prevents the garbage collector from moving objects while you hold pointers to them—critical for avoiding corruption.
public class UnsafeBasics
{
public unsafe void PointerArithmetic()
{
int[] numbers = { 10, 20, 30, 40, 50 };
// Pin the array so GC doesn't move it
fixed (int* ptr = numbers)
{
// Access via pointer
Console.WriteLine($"First element: {*ptr}");
// Pointer arithmetic
int* secondElement = ptr + 1;
Console.WriteLine($"Second element: {*secondElement}");
// Modify via pointer
*(ptr + 2) = 99;
Console.WriteLine($"Modified third: {numbers[2]}");
// Iterate with pointers
int* end = ptr + numbers.Length;
for (int* p = ptr; p < end; p++)
{
*p *= 2; // Double each value
}
}
Console.WriteLine($"After doubling: {string.Join(", ", numbers)}");
}
public unsafe int SumArrayUnsafe(int[] data)
{
int sum = 0;
fixed (int* ptr = data)
{
int* end = ptr + data.Length;
for (int* p = ptr; p < end; p++)
{
sum += *p;
}
}
return sum;
}
}
The fixed statement is crucial—it tells the garbage collector not to relocate the object while you're using pointers to it. Without fixed, the GC could move your array during collection, causing your pointer to reference invalid memory. Inside the fixed block, your pointer remains valid and safe to use.
Interfacing with Native Code
Native libraries often require pointers for input and output parameters. When calling Win32 APIs or third-party C libraries via P/Invoke, unsafe code becomes essential for passing data correctly. You can pass pointers to structs, arrays, or individual values directly to native functions.
The Marshal class provides safe alternatives for many scenarios, but sometimes direct pointers offer better performance or are the only option for complex data structures. When working with native code, understand both the managed and unmanaged memory layouts to avoid corruption.
using System.Runtime.InteropServices;
public class NativeMemoryExample
{
// Struct with explicit layout for native interop
[StructLayout(LayoutKind.Sequential)]
public struct NativeData
{
public int Id;
public double Value;
public long Timestamp;
}
public unsafe void ProcessNativeData()
{
// Allocate unmanaged memory
int size = sizeof(NativeData);
IntPtr buffer = Marshal.AllocHGlobal(size);
try
{
// Get pointer to unmanaged memory
NativeData* data = (NativeData*)buffer;
// Write to unmanaged memory
data->Id = 42;
data->Value = 3.14159;
data->Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
Console.WriteLine($"Native Data: ID={data->Id}, " +
$"Value={data->Value}, TS={data->Timestamp}");
// Could pass this pointer to native function
// ProcessNative(data);
}
finally
{
// Always free unmanaged memory
Marshal.FreeHGlobal(buffer);
}
}
// Example P/Invoke signature that expects a pointer
[DllImport("native.dll")]
private static extern unsafe int ProcessNative(NativeData* data);
// Using stackalloc for temporary buffers (no heap allocation)
public unsafe void UseStackAlloc()
{
// Allocate on stack - automatically freed when method exits
int* buffer = stackalloc int[128];
for (int i = 0; i < 128; i++)
{
buffer[i] = i * i;
}
Console.WriteLine($"Stack value at [10]: {buffer[10]}");
}
}
The stackalloc keyword allocates memory on the stack rather than the heap, avoiding garbage collection entirely. This is perfect for small temporary buffers in hot paths. The memory is automatically reclaimed when the method exits, so there's no need for cleanup. However, stack space is limited—allocating too much causes stack overflow exceptions.
Hands-On Example
Build a program that demonstrates pointer operations and compares unsafe and safe approaches. You'll see the mechanics of pointer arithmetic and understand when unsafe code actually helps.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
unsafe void DemonstratePointers()
{
int[] numbers = { 5, 10, 15, 20, 25 };
Console.WriteLine("Original array:");
Console.WriteLine(string.Join(", ", numbers));
fixed (int* ptr = numbers)
{
// Double every element via pointer
for (int i = 0; i < numbers.Length; i++)
{
*(ptr + i) *= 2;
}
}
Console.WriteLine("\nAfter doubling:");
Console.WriteLine(string.Join(", ", numbers));
}
unsafe void UseStackAlloc()
{
int* squares = stackalloc int[10];
for (int i = 0; i < 10; i++)
{
squares[i] = i * i;
}
Console.WriteLine("\nSquares (stackalloc):");
for (int i = 0; i < 10; i++)
{
Console.Write($"{squares[i]} ");
}
Console.WriteLine();
}
unsafe void PointerSizeDemo()
{
Console.WriteLine($"\nPointer sizes:");
Console.WriteLine($"sizeof(int*) = {sizeof(int*)} bytes");
Console.WriteLine($"sizeof(byte*) = {sizeof(byte*)} bytes");
Console.WriteLine($"sizeof(double*) = {sizeof(double*)} bytes");
}
DemonstratePointers();
UseStackAlloc();
PointerSizeDemo();
Steps:
- Init project:
dotnet new console -n UnsafeDemo
- Enter directory:
cd UnsafeDemo
- Update the .csproj file to enable unsafe blocks
- Replace Program.cs with the code above
- Execute:
dotnet run
Console:
Original array:
5, 10, 15, 20, 25
After doubling:
10, 20, 30, 40, 50
Squares (stackalloc):
0 1 4 9 16 25 36 49 64 81
Pointer sizes:
sizeof(int*) = 8 bytes
sizeof(byte*) = 8 bytes
sizeof(double*) = 8 bytes
The program demonstrates core unsafe operations: pointer arithmetic to modify array elements, stackalloc for stack-based allocation, and the sizeof operator for pointer sizes. All pointer types are 8 bytes on 64-bit systems regardless of what they point to.
Security and Safety Considerations
Buffer overruns become possible with unsafe code. Unlike safe arrays where the runtime validates every index, pointers let you access memory beyond allocation boundaries. A single off-by-one error can corrupt adjacent memory, crash your application, or create exploitable vulnerabilities. Always validate lengths before pointer operations.
Use the fixed statement religiously when taking addresses of managed objects. Without it, the garbage collector might relocate objects during collection, causing your pointers to reference freed or reused memory. This leads to non-deterministic crashes that are extremely difficult to debug.
Consider whether Span<T>, Memory<T>, or ArrayPool<T> solve your problem safely. These modern APIs provide nearly the same performance as unsafe code while maintaining bounds checking and type safety. Only reach for unsafe when profiling proves you need it and safe alternatives don't suffice.
In security-sensitive code, avoid unsafe operations entirely if possible. Code running with untrusted input should never use pointers without extensive validation. A malicious actor can exploit pointer misuse to read arbitrary memory or execute arbitrary code. The performance gain rarely justifies the security risk in exposed APIs.