Mert Özen Further With Every Line
Backend Advanced C# — #10

Interop and unsafe Code in C#: P/Invoke, Pointers and fixed

Mert Özen Nov 7, 2026 7 min 10 views
Interop and unsafe Code in C#: P/Invoke, Pointers and fixed

AllowUnsafeBlocks and pointers, pinning against the GC with fixed, calling libc with DllImport, string marshalling, callbacks with function pointers, and keeping unsafe code small with NativeMemory and spans.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We have an array holding the stock counts of three books. We take a pointer and change the second element directly through its memory address. Does this code compile in a console project created with the default settings? If it does, what does it print?

int[] stock = { 5, 3, 8 };

unsafe
{
    fixed (int* p = stock)
    {
        *(p + 1) = 42;
    }
}

Console.WriteLine(string.Join(", ", stock));

unsafe: Getting Permission from the Compiler

It doesn't compile:

Program.cs(3,1): error CS0227: Unsafe code may only appear if compiling with /unsafe

You can write pointers in C#, but you have to say "yes, I know" twice. First in the code: anything containing a pointer must be inside an unsafe block, method or type. Second in the project: you have to tell the compiler in the csproj that you allow unsafe code:

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

With that line added, the same code compiles and runs:

5, 42, 8

The word "unsafe" is a bit misleading. The code isn't dangerous; it's just that the safety guarantees the compiler and runtime give you don't apply inside this block. No index bounds checks, no type checks, no protection against the garbage collector moving objects. Correctness is your responsibility. That's also why there's a double lock: one tells the person reading the code "this part is different", the other tells whoever inherits the project "this project contains code like this".

Pointers, fixed and the Garbage Collector

The fixed line in the example is the most important part. While compacting the heap, the garbage collector can move objects around in memory. A managed reference knows about that move; the GC updates it. A raw pointer is just a number; if the array moves, the pointer stays at the old address and now points at something else. fixed pins the array in place until the end of the block and gives you the address of the first element as an int*.

*(p + 1) is pointer arithmetic: go one int (4 bytes) past the address p points to and write there. It's the same as p[1]. Everything we did with spans and refs in the previous two posts can be done with pointers too; the difference is that a span knows its length and checks bounds, and refs are checked by the compiler. Pointers have neither. In modern C#, most places where you'd need a pointer are now solved with spans; where pointers really remain is talking to native code.

P/Invoke: Calling a Native Function

P/Invoke (platform invoke) is the way to call a function in a library written in C from C#. Operating system APIs, a compression or image-processing library, a hardware SDK... You declare the function's signature as an extern method and say which library it's in with [DllImport]:

using System.Runtime.InteropServices;

Console.WriteLine(OperatingSystem.IsMacOS());
Console.WriteLine(Native.getpid() == Environment.ProcessId);
Console.WriteLine(Native.strlen("Dune"));
Console.WriteLine(Native.strlen("Café"));
Console.WriteLine("Café".Length);

static class Native
{
    [DllImport("libc")]
    public static extern int getpid();

    [DllImport("libc")]
    public static extern nuint strlen(string text);
}
True
True
4
5
4

This example ran on macOS and called the system's C library (libc). getpid returned the same process id the runtime gives through Environment.ProcessId. We wrote the library name without an extension; .NET completes the name for the platform (it searches by trying the .dll, .so or .dylib extension and, if needed, a lib prefix). The functions themselves are platform-specific, though: on Windows, the process id comes from GetCurrentProcessId in kernel32.dll. In a cross-platform application you pick the right signature with checks like OperatingSystem.IsWindows().

Notice that we didn't write unsafe. P/Invoke itself doesn't need unsafe; it's only needed if you use pointers in the signature.

Marshalling: How Data Crosses the Boundary

The last two lines are interesting: "Café" is 4 characters on the C# side, yet strlen said 5. C# strings are UTF-16, while C functions expect byte sequences ending in \0. At call time the runtime copies the string into native memory and converts it; on platforms other than Windows the default conversion is UTF-8, and "é" takes two bytes in UTF-8. This conversion work is called marshalling.

Not every type needs marshalling. int, long, double, nuint and structs made only of such types have the same memory layout on both sides (blittable types); the runtime passes them as they are. string, bool, arrays and classes get converted, and that has a cost. If you call a native function in a hot loop, building the signature from blittable types (a byte* or a pointer taken from a span instead of a string) is both fast and predictable. Also note that the counterpart of C's size_t is nuint: an integer the width of the platform's pointers.

[LibraryImport], which came with .NET 7, is a source generator that produces this marshalling code at compile time instead of at run time. We'll see what source generators are in the next post; on .NET 6 our tool is [DllImport].

Callbacks: Coming Back from Native Code to C#

Some C functions ask you for a function. The classic example is libc's qsort: it doesn't know what it's sorting and asks the function you give it to do the comparison. With function pointers from C# 9 and [UnmanagedCallersOnly], you can do this without creating a delegate:

using System.Runtime.InteropServices;

int[] stock = { 12, 3, 40, 7, 25 };

unsafe
{
    fixed (int* p = stock)
    {
        Native.qsort(p, (nuint)stock.Length, sizeof(int), &Native.CompareStock);
    }
}

Console.WriteLine(string.Join(", ", stock));

static unsafe class Native
{
    [DllImport("libc")]
    public static extern void qsort(void* items, nuint count, nuint size, delegate* unmanaged<void*, void*, int> compare);

    [UnmanagedCallersOnly]
    public static int CompareStock(void* a, void* b) => (*(int*)a).CompareTo(*(int*)b);
}
3, 7, 12, 25, 40

The attribute on CompareStock tells the runtime this method will be called directly from native code. &Native.CompareStock gives its address as a function pointer of type delegate* unmanaged<void*, void*, int>. qsort took the array's address, the element count and the element size, and called our method for every comparison. Because we pinned the array with fixed, the GC couldn't move it during the sort.

The old way was to declare a delegate and marshal it. It still works, but it has a pitfall: if the native side stores the callback and the GC collects the delegate, the native code calls a function that no longer exists. The static method a function pointer points to is always there.

Native Memory and Span

You might need to give a buffer to a native library, or allocate a large area the GC will never see. The NativeMemory class that came with .NET 6 corresponds to C's malloc/free. And as we said in the Span post, a span can sit in front of this memory too:

using System.Runtime.InteropServices;

unsafe
{
    byte* buffer = (byte*)NativeMemory.Alloc(16);
    try
    {
        var span = new Span<byte>(buffer, 16);
        span.Fill(0x2A);
        span[0] = 1;

        Console.WriteLine($"{buffer[0]} {buffer[1]} {span.Length}");
        Console.WriteLine(span.IndexOf((byte)1));
    }
    finally
    {
        NativeMemory.Free(buffer);
    }
}
1 42 16
0

We used the pointer only once, to create the span; after that it's safe, bounds-checked code with Fill, the indexer and IndexOf. This is the best way to keep unsafe code small: turn the pointer into a span at the boundary and do the work with the span. The try/finally is a must; the GC doesn't know about this memory, and if you don't call Free it leaks until the program ends. If you're writing a class that holds a native resource for a long time, the SafeHandle types and IDisposable we saw in the OOP series guarantee that cleanup.

Common Pitfalls

Pointers know no bounds. Let's read the fourth element of the same array, first with a pointer, then with a span:

int[] stock = { 5, 3, 8 };

unsafe
{
    fixed (int* p = stock)
    {
        int value = p[3];
        Console.WriteLine("pointer read past the end: no exception");
    }
}

try
{
    Span<int> span = stock;
    Console.WriteLine(span[3]);
}
catch (IndexOutOfRangeException ex)
{
    Console.WriteLine($"span: {ex.GetType().Name}");
}
pointer read past the end: no exception
span: IndexOutOfRangeException

The pointer read past the end of the array without any objection; it brought back whatever was there (part of another object, padding). Had it written, it would have corrupted another object, and the bug would show up much later in an unrelated place. The span threw IndexOutOfRangeException on the same read. When working with pointers, you compute the bounds of every access yourself.

Letting a pointer escape the fixed block. If you store an address taken inside fixed in a field or a variable outside the block, the compiler can't always stop you. When the block ends the pin is released; when the GC moves the array, the address you hold is invalid. These bugs don't show up in tests, in short runs where the GC never runs; they appear in production under load.

A library from the wrong platform. A P/Invoke signature isn't checked at compile time; the library is loaded on the first call. If you try a Windows-only call on macOS:

using System.Runtime.InteropServices;

try
{
    Console.WriteLine(Win32.GetCurrentProcessId());
}
catch (DllNotFoundException ex)
{
    Console.WriteLine(ex.GetType().Name);
    Console.WriteLine(ex.Message.Split('.')[0]);
}

static class Win32
{
    [DllImport("kernel32")]
    public static extern uint GetCurrentProcessId();
}
DllNotFoundException
Unable to load shared library 'kernel32' or one of its dependencies

Similarly, if the types in the signature don't match the C side (64 bits instead of 32, the wrong calling convention), you don't even get an exception; you get wrong values or a process crash. Derive signatures carefully from the documentation and test on every platform.

Escaping to unsafe for no reason. The intuition that "pointers are faster" is usually wrong. The JIT can remove most bounds checks in span and array loops. Use unsafe only when you have to talk to native code, or on a measured bottleneck where you've seen that spans aren't enough.

A Small Experiment

★☆☆ Add your own name and a word with a few accented characters to the strlen example. Verify the difference between Length and strlen by comparing with Encoding.UTF8.GetByteCount.

★★☆ Change the qsort example to sort the stock counts from largest to smallest. Then delete the [UnmanagedCallersOnly] attribute and build; what does the compiler say?

★★★ Define a blittable struct BookRecord { public long Isbn; public int Stock; } and sort a BookRecord[] array with qsort, by stock first and by ISBN on ties. What does sizeof(BookRecord) come out as, and why isn't it 12?

We mentioned that P/Invoke marshalling code can be generated at compile time. In the next post we look at that idea itself: source generators, which plug into the compiler and write C# code for you during the build, and a simple example with an incremental generator.