How to Use Unsafe Blocks and Pointers in C
While C# is primarily a memory-safe, garbage-collected language, it
provides the unsafe keyword to enable direct memory
manipulation through raw pointers for low-level systems programming.
This article covers how to enable unsafe code in your project, the
syntax and behavior of C# pointers, the necessity of the
fixed statement for pinning managed memory, and best
practices for balancing extreme performance with application
stability.
Enabling Unsafe Code
By default, the C# compiler restricts direct pointer manipulation to
prevent memory corruption and security vulnerabilities. To use pointers,
you must explicitly inform the compiler by modifying your project file
(.csproj):
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>Alternatively, you can pass the -unsafe flag directly to
the dotnet build or csc command-line
tools.
The unsafe Keyword
The unsafe keyword creates a context where type-safety
verification is relaxed, allowing pointer types. You can apply
unsafe to entire classes, individual methods, properties,
or isolated code blocks:
// Unsafe method
public unsafe void ClearMemory(byte* buffer, int length)
{
for (int i = 0; i < length; i++)
{
*(buffer + i) = 0;
}
}
// Unsafe block inside a safe method
public void ProcessData()
{
int value = 42;
unsafe
{
int* pointerToValue = &value;
*pointerToValue = 100;
}
}Pointer Syntax and Supported Types
Pointers in C# function similarly to their C and C++ counterparts:
*(indirection operator): Declares a pointer or dereferences an existing one.&(address-of operator): Retrieves the memory address of an unmanaged variable.->(pointer member access): Accesses fields of a struct through a pointer.
Pointers cannot point to reference types or structs that contain reference types because the Garbage Collector (GC) cannot track references via pointers. They can only point to unmanaged types, which include:
- Primitive numeric types (
sbyte,byte,short,int,long,float,double,decimal) boolandcharenumtypes- Other pointer types (e.g.,
void*,int**) - User-defined
structtypes containing only unmanaged types
struct Vector3
{
public float X, Y, Z;
}
unsafe
{
Vector3 vec = new Vector3 { X = 1.0f, Y = 2.0f, Z = 3.0f };
Vector3* pVec = &vec;
pVec->X = 10.0f; // Direct field access
}Pinning Memory with the
fixed Statement
The .NET Garbage Collector periodically moves managed objects around in physical memory to reduce fragmentation (heap compaction). If a managed object moves while an unmanaged pointer is reading or writing to it, the pointer becomes invalid, leading to memory corruption.
The fixed statement pins a managed variable in place,
preventing the GC from relocating it for the duration of the statement
block:
byte[] data = new byte[1024];
unsafe
{
// Pin the managed array so the GC won't move it
fixed (byte* pData = data)
{
// pData holds a stable memory address
byte* current = pData;
for (int i = 0; i < 1024; i++)
{
*current++ = (byte)(i % 256);
}
} // Memory is automatically unpinned here
}Stack Allocation
(stackalloc)
For high-throughput routines that require temporary buffers, C#
offers the stackalloc operator. This allocates memory
directly from the call stack rather than the managed heap, bypassing
garbage collection overhead entirely:
unsafe
{
// Allocates memory for 256 integers on the stack
int* stackBuffer = stackalloc int[256];
for (int i = 0; i < 256; i++)
{
stackBuffer[i] = i * 2;
}
}Note: Stack allocations are automatically freed when the
enclosing method returns, but requesting too much stack memory can cause
a StackOverflowException.
Common Use Cases and Risks
Unsafe blocks and pointers are standard in several systems-level domains:
- Platform Invoke (P/Invoke): Exchanging raw memory buffers, function pointers, and structs with native C/C++ libraries.
- Performance-Critical Code: Bypassing array bounds checks in high-performance computing, audio processing, or game engine development.
- Direct Hardware/Driver Access: Reading from or writing to memory-mapped hardware interfaces.
Using raw pointers removes all CLR safety guarantees. Common risks include:
- Buffer Overflows: Writing past the end of allocated memory will corrupt adjacent memory structures.
- Dangling Pointers: Dereferencing a pointer to an unpinned managed object that has moved or a stack buffer whose method has already returned.
- Memory Leaks: Unmanaged memory allocated via native
APIs (such as
Marshal.AllocHGlobal) must be explicitly released, as the GC ignores it.