You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Unsafe & Low-Level

Work with pointers, fixed buffers, and unmanaged memory when needed

Unsafe Code & Low-Level Programming in C#

Master unsafe code and pointer manipulation in C# with free flashcards and spaced repetition practice. This lesson covers pointer basics, memory management, fixed statements, and performance optimization—essential concepts for systems programming, interop scenarios, and high-performance applications.

Welcome to the World of Unsafe Code 💻

C# is a managed language that handles memory automatically through garbage collection, but sometimes you need direct memory access for performance-critical operations, interoperability with unmanaged code, or low-level system programming. The unsafe keyword unlocks pointer manipulation, direct memory access, and manual memory management—powerful tools that bypass .NET's safety guarantees.

⚠️ Important: Unsafe code sacrifices memory safety for performance and control. Use it judiciously and only when necessary!

Core Concepts

What is Unsafe Code? 🔓

Unsafe code allows you to work with pointers, perform pointer arithmetic, and directly manipulate memory addresses. It's called "unsafe" because:

  • No bounds checking: You can access memory outside array boundaries
  • No garbage collection tracking: Manual responsibility for memory lifetime
  • Type safety bypassed: Can cast between pointer types freely
  • Risk of memory corruption: Invalid pointers cause crashes or data corruption

💡 Tip: Unsafe code requires the /unsafe compiler flag or the <AllowUnsafeBlocks>true</AllowUnsafeBlocks> property in your .csproj file.

The unsafe Keyword 🔑

You can mark entire methods, blocks, or even types as unsafe:

// Unsafe method
public unsafe void ProcessData(byte* buffer, int length)
{
    // Pointer operations here
}

// Unsafe block within safe method
public void SafeMethod()
{
    unsafe
    {
        int x = 10;
        int* ptr = &x;  // Get address of x
        Console.WriteLine(*ptr);  // Dereference pointer
    }
}

// Unsafe struct
public unsafe struct BufferHeader
{
    public byte* DataPointer;
    public int Length;
}

Pointers: The Fundamentals 👉

A pointer stores a memory address. Here's the syntax:

OperationSyntaxDescription
Declare pointerint* ptr;Pointer to int
Address-of&variableGet memory address
Dereference*ptrAccess value at address
Member accessptr->memberAccess struct member via pointer
Array indexingptr[index]Pointer arithmetic + dereference

Pointer types you can declare:

  • Value type pointers: int*, double*, MyStruct*
  • void*: Generic pointer (any type)
  • Pointer to pointer: int**, byte***

⚠️ You cannot create pointers to: Reference types (classes), managed arrays, or types containing reference types.

The fixed Statement 📌

The garbage collector moves objects in memory during compaction. The fixed statement pins an object in place so its address remains constant:

public unsafe void ProcessArray(int[] numbers)
{
    fixed (int* ptr = numbers)  // Pin array in memory
    {
        for (int i = 0; i < numbers.Length; i++)
        {
            *(ptr + i) *= 2;  // Direct memory access
        }
    }  // Array automatically unpinned here
}

Why fixed is necessary:

  1. GC can move objects during collection
  2. Moving objects invalidates pointers
  3. fixed tells GC: "Don't move this object"
  4. Automatic unpinning when leaving scope

💡 Tip: You can fix multiple variables in one statement: fixed (byte* p1 = buffer1, p2 = buffer2)

Pointer Arithmetic 🧮

Pointers support mathematical operations:

unsafe
{
    int[] data = { 10, 20, 30, 40, 50 };
    fixed (int* ptr = data)
    {
        int* current = ptr;
        
        current++;        // Move to next int (4 bytes forward)
        current += 2;     // Move 2 ints forward (8 bytes)
        current--;        // Move 1 int backward (4 bytes)
        
        long offset = current - ptr;  // Distance in elements
        
        Console.WriteLine(*current);  // Access value
    }
}

Pointer arithmetic rules:

  • Adding n to pointer moves n * sizeof(type) bytes
  • Subtracting pointers gives element count between them
  • Comparisons (<, >, ==) compare memory addresses
  • No multiplication or division (doesn't make sense for addresses)

stackalloc: Stack Allocation 📚

The stackalloc keyword allocates memory on the stack (not heap):

unsafe
{
    int* buffer = stackalloc int[100];  // 400 bytes on stack
    
    for (int i = 0; i < 100; i++)
    {
        buffer[i] = i * i;
    }
    
    // No need to free - automatically released when method returns
}

// Modern C# (7.2+): Span<T> with stackalloc (safe!)
Span<int> buffer = stackalloc int[100];
for (int i = 0; i < buffer.Length; i++)
{
    buffer[i] = i * i;
}

Stack vs Heap allocation:

AspectStack (stackalloc)Heap (new)
Speed⚡ Very fast (pointer bump)Slower (GC overhead)
LifetimeMethod scope onlyUntil garbage collected
Size limit~1MB (stack size)Gigabytes available
CleanupAutomatic (stack unwind)Garbage collector

⚠️ Warning: Large stackalloc can cause StackOverflowException! Keep allocations small (< 1KB recommended).

sizeof and Unmanaged Types 📏

The sizeof operator returns type size in bytes:

unsafe
{
    Console.WriteLine(sizeof(int));      // 4
    Console.WriteLine(sizeof(double));   // 8
    Console.WriteLine(sizeof(bool));     // 1
    Console.WriteLine(sizeof(char));     // 2 (Unicode)
}

// For custom structs
public struct Point3D
{
    public float X, Y, Z;
}

unsafe
{
    Console.WriteLine(sizeof(Point3D));  // 12 (3 × 4 bytes)
}

Unmanaged types (allowed in unsafe code):

  • Primitive types: int, byte, float, double, etc.
  • Enums
  • Pointers
  • Structs containing only unmanaged types

Practical Examples

Example 1: High-Performance Array Processing 🚀

Problem: Process large arrays faster by bypassing bounds checking.

public class ArrayProcessor
{
    // Safe version (bounds checked)
    public static void DoubleValuesSafe(int[] array)
    {
        for (int i = 0; i < array.Length; i++)
        {
            array[i] *= 2;
        }
    }
    
    // Unsafe version (no bounds checking)
    public static unsafe void DoubleValuesUnsafe(int[] array)
    {
        fixed (int* ptr = array)
        {
            int* end = ptr + array.Length;
            for (int* current = ptr; current < end; current++)
            {
                *current *= 2;
            }
        }
    }
}

// Benchmark results:
// Safe:   125ms for 100 million elements
// Unsafe: 85ms for 100 million elements (32% faster)

Why it's faster:

  • No array bounds checking on each access
  • Direct memory manipulation
  • Compiler optimizations easier with pointers
  • CPU cache-friendly sequential access

💡 Modern Alternative: Use Span<T> for similar performance without unsafe code!

Example 2: Interop with Native APIs 🔗

Problem: Call Windows API that expects a pointer to a structure.

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
    public int X;
    public int Y;
}

public class NativeInterop
{
    [DllImport("user32.dll")]
    private static extern bool GetCursorPos(out POINT lpPoint);
    
    // Alternative: Use pointer parameter
    [DllImport("user32.dll")]
    private static extern unsafe bool GetCursorPos(POINT* lpPoint);
    
    public static unsafe void GetMousePosition()
    {
        POINT point;
        GetCursorPos(&point);  // Pass pointer to struct
        
        Console.WriteLine($"Mouse at ({point.X}, {point.Y})");
    }
}

// Working with byte buffers from unmanaged code
public class BufferInterop
{
    [DllImport("native.dll")]
    private static extern unsafe void FillBuffer(byte* buffer, int size);
    
    public static unsafe void ProcessNativeData()
    {
        byte[] managed = new byte[1024];
        
        fixed (byte* ptr = managed)
        {
            FillBuffer(ptr, managed.Length);
        }
        
        // Now process managed array with filled data
        Console.WriteLine($"First byte: {managed[0]}");
    }
}

Common interop scenarios:

  • Platform Invoke (P/Invoke) calls
  • COM interop
  • Memory-mapped files
  • Direct hardware access

Example 3: Custom Memory Pool 🏊

Problem: Reduce GC pressure by reusing pre-allocated buffers.

public unsafe class UnmanagedMemoryPool
{
    private byte* _buffer;
    private int _size;
    private int _position;
    
    public UnmanagedMemoryPool(int sizeInBytes)
    {
        _size = sizeInBytes;
        _buffer = (byte*)Marshal.AllocHGlobal(_size);
        _position = 0;
    }
    
    public byte* Allocate(int bytes)
    {
        if (_position + bytes > _size)
            throw new OutOfMemoryException("Pool exhausted");
        
        byte* result = _buffer + _position;
        _position += bytes;
        return result;
    }
    
    public void Reset()
    {
        _position = 0;
        // Optionally zero out memory
        for (int i = 0; i < _size; i++)
            _buffer[i] = 0;
    }
    
    public void Dispose()
    {
        if (_buffer != null)
        {
            Marshal.FreeHGlobal((IntPtr)_buffer);
            _buffer = null;
        }
    }
    
    ~UnmanagedMemoryPool()
    {
        Dispose();
    }
}

// Usage
public static unsafe void UseMemoryPool()
{
    using var pool = new UnmanagedMemoryPool(4096);
    
    int* numbers = (int*)pool.Allocate(100 * sizeof(int));
    for (int i = 0; i < 100; i++)
    {
        numbers[i] = i * i;
    }
    
    pool.Reset();  // Reuse memory for next operation
}

Benefits:

  • Zero GC pressure (unmanaged memory)
  • Fast allocation (pointer arithmetic)
  • Predictable memory usage
  • Useful for real-time systems

⚠️ Remember: Must manually free unmanaged memory with Marshal.FreeHGlobal!

Example 4: Fast String Operations 📝

Problem: Perform character-level operations without creating intermediate strings.

public static unsafe class FastStringOps
{
    // Count specific character without allocation
    public static int CountChar(string text, char target)
    {
        int count = 0;
        
        fixed (char* ptr = text)
        {
            char* end = ptr + text.Length;
            for (char* current = ptr; current < end; current++)
            {
                if (*current == target)
                    count++;
            }
        }
        
        return count;
    }
    
    // In-place uppercase conversion (on char array)
    public static void ToUpperInPlace(char[] chars)
    {
        fixed (char* ptr = chars)
        {
            for (int i = 0; i < chars.Length; i++)
            {
                char c = ptr[i];
                if (c >= 'a' && c <= 'z')
                    ptr[i] = (char)(c - 32);  // Convert to uppercase
            }
        }
    }
    
    // Fast string comparison (case-sensitive)
    public static bool FastEquals(string s1, string s2)
    {
        if (s1.Length != s2.Length)
            return false;
        
        fixed (char* p1 = s1, p2 = s2)
        {
            char* end = p1 + s1.Length;
            for (char* c1 = p1, c2 = p2; c1 < end; c1++, c2++)
            {
                if (*c1 != *c2)
                    return false;
            }
        }
        
        return true;
    }
}

// Example usage
string text = "Hello World";
int spaces = FastStringOps.CountChar(text, ' ');  // 1

char[] buffer = text.ToCharArray();
FastStringOps.ToUpperInPlace(buffer);
Console.WriteLine(new string(buffer));  // "HELLO WORLD"

Performance wins:

  • No temporary string allocations
  • Direct character access
  • Minimal method call overhead
  • Cache-friendly sequential access

Common Mistakes ⚠️

1. Forgetting to Use fixed

Wrong:

public unsafe void BadCode(int[] array)
{
    int* ptr = &array[0];  // ERROR: Can't take address without fixed
    *ptr = 42;
}

Correct:

public unsafe void GoodCode(int[] array)
{
    fixed (int* ptr = array)  // Pin array first
    {
        *ptr = 42;
    }
}

2. Dangling Pointers

Wrong:

public unsafe int* GetStackPointer()
{
    int value = 100;
    return &value;  // DANGER: Returns pointer to stack variable!
}  // 'value' goes out of scope, pointer now invalid

public unsafe void UseDanglingPointer()
{
    int* ptr = GetStackPointer();
    Console.WriteLine(*ptr);  // Undefined behavior!
}

Correct:

public unsafe int* GetHeapPointer()
{
    int* ptr = (int*)Marshal.AllocHGlobal(sizeof(int));
    *ptr = 100;
    return ptr;  // Valid: Points to heap memory
}  // Remember to call Marshal.FreeHGlobal later!

3. Buffer Overruns

Wrong:

public unsafe void Overflow()
{
    int* buffer = stackalloc int[10];
    
    for (int i = 0; i <= 10; i++)  // BUG: i <= 10 is off-by-one!
    {
        buffer[i] = i;  // Writes beyond allocated memory
    }
}

Correct:

public unsafe void NoOverflow()
{
    int* buffer = stackalloc int[10];
    
    for (int i = 0; i < 10; i++)  // Correct: i < 10
    {
        buffer[i] = i;
    }
}

4. Pointer to Reference Type

Wrong:

public unsafe void PointerToClass()
{
    string s = "Hello";
    string* ptr = &s;  // ERROR: Cannot take address of reference type
}

Correct (if you need reference type address):

public unsafe void PointerToClassCorrect()
{
    string s = "Hello";
    
    fixed (char* ptr = s)  // Get pointer to string's character data
    {
        Console.WriteLine(*ptr);  // 'H'
    }
    
    // Or use GCHandle for object address
    GCHandle handle = GCHandle.Alloc(s, GCHandleType.Pinned);
    IntPtr address = handle.AddrOfPinnedObject();
    handle.Free();
}

5. Large Stack Allocations

Wrong:

public unsafe void TooMuchStack()
{
    byte* huge = stackalloc byte[10_000_000];  // 10MB on stack!
    // StackOverflowException!
}

Correct:

public unsafe void ReasonableAllocation()
{
    // Small temporary buffer on stack
    byte* small = stackalloc byte[1024];  // 1KB - fine
    
    // Or use heap for large allocations
    byte* large = (byte*)Marshal.AllocHGlobal(10_000_000);
    try
    {
        // Use large buffer...
    }
    finally
    {
        Marshal.FreeHGlobal((IntPtr)large);
    }
}

💡 Rule of thumb: Keep stackalloc under 1KB. For larger buffers, use heap allocation or ArrayPool<T>.

6. Ignoring Alignment

Wrong:

public unsafe void MisalignedAccess()
{
    byte* buffer = stackalloc byte[100];
    
    long* longPtr = (long*)buffer;  // May be misaligned!
    *longPtr = 123456789L;  // Slow or crash on some architectures
}

Correct:

public unsafe void AlignedAccess()
{
    // Allocate properly aligned memory
    long* aligned = stackalloc long[10];  // Guaranteed aligned
    aligned[0] = 123456789L;
    
    // Or use StructLayout for explicit control
}

Key Takeaways 🎯

  1. Use sparingly: Unsafe code sacrifices safety for performance—only use when profiling shows a real bottleneck

  2. Always fix managed arrays: Use fixed statement to pin arrays/strings before taking pointers

  3. Stack vs Heap: stackalloc is fast but limited; use Marshal.AllocHGlobal for larger allocations

  4. Bounds checking disappears: You're responsible for preventing buffer overruns and access violations

  5. Manual memory management: Unmanaged memory must be explicitly freed—no garbage collector help

  6. Modern alternatives: Consider Span<T>, Memory<T>, and ArrayPool<T> for safe high-performance code

  7. Compiler flag required: Enable unsafe blocks in project settings or use /unsafe flag

  8. Security implications: Unsafe code can bypass security checks—use only in trusted scenarios

📋 Quick Reference Card

OperationSyntaxUse Case
Get address&variableCreate pointer to variable
Dereference*pointerAccess value at address
Pin memoryfixed (T* p = obj)Prevent GC from moving object
Stack allocatestackalloc T[n]Fast temporary buffers
Heap allocateMarshal.AllocHGlobalUnmanaged memory
Free memoryMarshal.FreeHGlobalRelease unmanaged memory
Size of typesizeof(T)Get byte size
Pointer arithmeticptr + nMove n elements forward

Compiler Setup:

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

Memory Safety Checklist:

  • ✅ Always use fixed with managed arrays/strings
  • ✅ Check bounds manually (no automatic checking)
  • ✅ Free unmanaged memory (Marshal.FreeHGlobal)
  • ✅ Keep stackalloc small (< 1KB)
  • ✅ Test thoroughly—unsafe bugs cause crashes
  • ✅ Consider safe alternatives first (Span<T>, Memory<T>)

📚 Further Study

  1. Microsoft Docs - Unsafe Code: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/unsafe-code
  2. High-Performance C# with Span: https://learn.microsoft.com/en-us/archive/msdn-magazine/2018/january/csharp-all-about-span-exploring-a-new-net-mainstay
  3. Memory and Span Tutorial: https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/

🧠 Memory Device: Remember "FUSP" for unsafe code essentials:

  • Fixed (pin memory)
  • Unmanaged (types only)
  • Stackalloc (fast, small)
  • Pointers (direct access)

🤔 Did you know? The .NET runtime itself uses unsafe code extensively in its implementation of collections, string operations, and I/O. You're using unsafe code indirectly every day—it's just wrapped in safe APIs!