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)
  • bool and char
  • enum types
  • Other pointer types (e.g., void*, int**)
  • User-defined struct types 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.