1. Introduction
In the previous lecture we talked about how C# and the .NET Runtime take care of most memory management concerns for you. However, sometimes you need or want more low-level control over memory, especially for performance tuning or interacting with unmanaged code (for example, system APIs written in C++). C# provides a set of features for these purposes which are powerful but require careful use.
When we say "low-level memory management" in C#, we usually mean:
- Direct pointer work: Accessing memory by address, like in C/C++.
- Allocating memory outside the GC heap: Using memory that the garbage collector doesn't track.
- Managing resources at the managed/unmanaged boundary: Efficiently interacting with native libraries.
In C# normal code is not allowed to directly manipulate memory addresses (pointers) to ensure safety and stability. However, for low-level operations you can use pointers inside an unsafe context.
What is an unsafe context?
A block or method marked with the unsafe keyword allows you to use pointer syntax and perform operations that CLR doesn't verify for safety. Code in an unsafe context is no safer than native code. To use unsafe code the project must be compiled with the /unsafe option.
Example: Declaring an unsafe method and block
public unsafe class UnsafeExamples
{
// Unsafe method
public static unsafe void ManipulatePointer(int* ptr)
{
Console.WriteLine($"Value at pointer: {*ptr}");
*ptr = 200; // Change the value at the address
}
public static void DemoUnsafeBlock()
{
int value = 100;
unsafe // Unsafe block inside a regular method
{
int* ptr = &value; // Get the address of the variable
ManipulatePointer(ptr); // Pass the pointer to an unsafe method
Console.WriteLine($"New value: {value}"); // Output: New value: 200
}
}
}
Pointer types
In C# you can declare pointers to value types (int*, bool*, MyStruct*) and to void (void* for a generic pointer). You cannot declare pointers to reference types directly, but you can get a pointer to a field of a reference type if that field is a value type.
Example: Different pointer types
unsafe
{
double d = 123.45;
double* dPtr = &d; // Pointer to double
int[] numbers = { 1, 2, 3 };
fixed (int* arrPtr = numbers) // 'fixed' pins the object in memory so GC won't move it
{
Console.WriteLine($"First element of array: {arrPtr[0]}");
Console.WriteLine($"Second element of array: {*(arrPtr + 1)}");
}
}
Pointer operators
- & (address): Gets the address of a variable.
- * (dereference): Gets the value at an address.
- -> (member access): Access a member of a struct/class via a pointer to it (only for value types).
- [] (indexing): Access array elements via a pointer (like in C/C++).
2. Pinned and allocated memory blocks
When you work with pointers it's important that the object the pointer refers to is not moved by the garbage collector while you operate on it. For that you use the fixed and stackalloc keywords.
The fixed operator
The fixed operator "pins" a reference-type variable in memory, preventing it from being moved by the garbage collector for the duration of the fixed block. This is critical when you pass pointers to managed objects to unmanaged code or work with them directly.
Example: Using fixed with arrays
public unsafe class FixedExample
{
public static void ProcessFixedArray()
{
byte[] buffer = new byte[10];
// Fill the buffer
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i + 1);
}
unsafe
{
// Pin the array in memory, get a pointer to its first element
fixed (byte* p = buffer)
{
// Now you can work with p safely knowing the array won't be moved
Console.WriteLine($"Value at first byte: {*p}");
Console.WriteLine($"Value at second byte: {*(p + 1)}");
// You can pass 'p' to a native function that expects a pointer
} // After exiting the 'fixed' block, the array can be moved by the GC
}
}
}
fixed can also be used with struct fields or strings.
Allocating on the stack: stackalloc
stackalloc lets you allocate a block of memory on the stack. It's very fast, but the memory is only available until the current method returns. The allocated memory is not managed by the garbage collector.
- Advantages: Very fast allocation, no GC overhead, deterministic memory release.
- Disadvantages: Limited size (the stack is relatively small), risk of stack overflow (StackOverflowException) with too large allocations.
- Use cases: Ideal for small, short-lived buffers.
Example: Using stackalloc
public unsafe class StackAllocExample
{
public static void ProcessStackAlloc()
{
unsafe
{
// Allocate 10 ints on the stack
int* numbers = stackalloc int[10];
for (int i = 0; i < 10; i++)
{
numbers[i] = i * 10;
}
Console.WriteLine($"Value of first element: {numbers[0]}");
Console.WriteLine($"Value of fifth element: {numbers[4]}");
} // Memory is automatically released when this method exits.
}
}
The stackalloc operator can be used with Span<T>, which makes working with such memory safer, because Span<T> is a managed type (a struct) that provides safe access to contiguous memory blocks. More about Span in the next lecture.
Example: stackalloc with Span<T>
using System;
public class StackAllocWithSpan
{
public static void DemoSpanStackAlloc()
{
// Stack allocation, but work through a safe Span<int>
Span<int> buffer = stackalloc int[10];
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = i * 2;
}
Console.WriteLine($"First element of Span: {buffer[0]}");
Console.WriteLine($"Last element of Span: {buffer[9]}");
// You can pass Span<T> to methods that accept it
PrintSpan(buffer);
}
public static void PrintSpan(Span<int> s)
{
foreach (var item in s)
{
Console.Write($"{item} ");
}
Console.WriteLine();
}
}
This is a hybrid approach: stack allocation (low-level), but safe access via Span<T> (high-level).
3. Interoperability with unmanaged code (P/Invoke)
Platform Invoke (P/Invoke) is a mechanism that allows C# code to call functions from unmanaged libraries (for example, Windows API DLLs or Linux .so files). This is a fundamental aspect of low-level memory management, because you often pass pointers to data between the managed and unmanaged worlds.
Declaring external functions
You use the DllImport attribute to declare static extern methods that map to native functions.
Example 3.1: Calling a Windows API function
using System.Runtime.InteropServices;
public class PInvokeExample
{
// Import the native MessageBox function from user32.dll
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);
public static void ShowMessageBox()
{
// Call the native function
MessageBox(IntPtr.Zero, "Hello from C#!", "Window title", 0);
}
}
Data marshaling
When calling native functions the .NET Runtime performs marshaling — converting data types between managed and unmanaged formats. For example, string in C# may be marshaled as char* or wchar_t* in C++.
For more complex marshaling you can use the MarshalAs attribute and the Marshal class.
Example: Marshaling structs
using System.Runtime.InteropServices;
// This struct will be marshaled to a native struct
[StructLayout(LayoutKind.Sequential)] // Indicates fields should be laid out sequentially
public struct NativePoint
{
public int X;
public int Y;
}
public class StructMarshalExample
{
[DllImport("your_native_lib.dll")] // Example: function in a native library
public static extern void ProcessPoint(NativePoint point);
[DllImport("your_native_lib.dll")]
public static extern void FillPoint(out NativePoint point); // Accepts a pointer to the struct
public static void DemoStructMarshal()
{
NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
ProcessPoint(myPoint); // The struct will be marshaled by value (copied)
NativePoint resultPoint;
FillPoint(out resultPoint); // The struct will be filled by the native function
Console.WriteLine($"Point from native: ({resultPoint.X}, {resultPoint.Y})");
}
}
4. GCHandle and pinning objects
GCHandle is a struct that lets you get a handle to an object in the managed heap and, if needed, temporarily pin it, preventing it from being moved or collected by the GC. This is useful when you need to provide a stable pointer to a managed object to unmanaged code.
Example: Pinning an object with GCHandle
using System.Runtime.InteropServices;
public class GCHandleExample
{
public static void PinObject()
{
byte[] data = new byte[100];
GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // Pin the array in memory
try
{
IntPtr pointer = handle.AddrOfPinnedObject(); // Get the pointer to the pinned object
Console.WriteLine($"Address of pinned array: {pointer:X}");
// Now 'pointer' can be safely passed to a native function.
// The native function can work with this memory directly.
Marshal.WriteByte(pointer, 0, 255); // Change the first byte via the pointer
Console.WriteLine($"First byte of array: {data[0]}"); // Output: 255
}
finally
{
if (handle.IsAllocated)
{
handle.Free(); // Free the handle, allow GC to manage the object again
}
}
}
}
A deep dive into P/Invoke and GCHandle is beyond the scope of this course, but they can be very useful if you want to call Windows libraries directly. At least now you know where to dig next.
GO TO FULL VERSION