<< Home

Chapter 17: Interop and Unsafe Code

C# runs on a managed runtime, which is a powerful environment for safety and productivity. Sometimes you need to cross the boundary into native code for platform APIs, performance, or existing libraries. This chapter explains how to call native functions, control memory with stackalloc and fixed, work with pointers and pinning, describe data layouts for marshalling, decide when unsafe constructs are appropriate, and verify correctness with tests.

Calling native code

The primary interop mechanism is Platform Invocation Services, often called P/Invoke. You declare a managed signature with [DllImport] or the modern source-generated [LibraryImport] and the runtime marshals parameters across the boundary. You choose the target library, calling convention, character set, and error handling. On .NET you can call system libraries and third-party native libraries on Windows, Linux, and macOS; the exact library names differ by platform.

P/Invoke basics with [DllImport]

[DllImport] binds a managed method to a native export. Specify the library, entry point when it differs, and marshalling hints. The example shows different libraries per platform using conditional compilation.

using System;
using System.Runtime.InteropServices;

internal static partial class Native
{
#if WINDOWS
  private const string Lib = "kernel32";
  [DllImport(Lib, SetLastError = true, ExactSpelling = true)]
  public static extern uint GetTickCount();
#else
  private const string Lib = "libc";
  [DllImport(Lib, ExactSpelling = true)]
  public static extern int printf(string format, __arglist);
#endif
}

public static class Demo
{
  public static void Main()
  {
#if WINDOWS
    uint ms = Native.GetTickCount();
    Console.WriteLine($"Tick count: {ms}");
#else
    Native.printf("Hello from libc: %s\n", __arglist("C#"));
#endif
  }
}
💡 Use ExactSpelling and CharSet to control name decoration and character marshalling. For Win32 APIs that set thread-local errors, pass SetLastError = true and read it with Marshal.GetLastWin32Error().

Source-generated marshalling

[LibraryImport] (the DllImportGenerator) moves marshalling work to compile time and can improve startup and throughput. It uses a partial method pattern that the generator fills in. This reduces runtime reflection and supports advanced custom marshalling.

using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

internal static partial class LibC
{
  [LibraryImport("libc", EntryPoint = "strlen")]
  internal static partial nuint strlen(byte* s);
}

unsafe class SGDemo
{
  static void Main()
  {
    byte* bytes = stackalloc byte[] { (byte)'h', (byte)'i', 0 };
    nuint n = LibC.strlen(bytes);
    System.Console.WriteLine(n);
  }
}
⚠️ The availability of [LibraryImport] depends on your target framework and SDK. Check your project’s target for support and keep the package and SDK in sync with your .NET version.

Reverse interop

When native code needs to call into managed code, mark a static method with [UnmanagedCallersOnly] and obtain a function pointer. Export this pointer to native code through an embedding API or by passing it to a registration function.

using System;
using System.Runtime.InteropServices;

public static class Callbacks
{
  [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
  public static int Add(int a, int b) => a + b;
}

unsafe class ReverseDemo
{
  static void Main()
  {
    delegate* unmanaged[Cdecl]<int,int,int> fp = (delegate* unmanaged[Cdecl]<int,int,int>)(nint)Marshal.GetFunctionPointerForDelegate(
      (Func<int,int,int>)((x, y) => x + y)  /* … choose strategy to obtain a stable pointer … */);
    int sum = fp(2, 3);
    Console.WriteLine(sum);
  }
}

In production code prefer stable exports using AOT or a hosting API instead of transient delegates, and ensure lifetime management for any delegate whose pointer you share.

stackalloc and fixed

stackalloc allocates a block on the current stack frame for short-lived buffers. fixed creates a pinned pointer to a managed object or a fixed-size buffer inside an unsafe struct. Both are useful at interop boundaries where a contiguous, pinned region is required.

stackalloc with Span<T> for safe slices

stackalloc returns a pointer in unsafe code or a Span<T> in safe code. Span<T> enables bounds-checked slicing without garbage collection pressure, which is ideal when preparing data for native calls.

using System;

static class StackallocDemo
{
  static void Main()
  {
    Span<byte> buffer = stackalloc byte[256];
    buffer.Clear();
    var slice = buffer.Slice(0, 8);
    for (int i = 0; i < slice.Length; i++) slice[i] = (byte)i;
    Console.WriteLine(slice.Length);
  }
}
💡 Use stackalloc for small buffers sized from known upper bounds. For variable or large sizes, heap allocation is safer; otherwise you risk stack overflow.

The fixed statement

fixed obtains a stable pointer so the garbage collector cannot move the target while native code runs. You can pin arrays, strings, or fields in an unsafe struct that declares fixed-size buffers.

unsafe struct Packet
{
  public fixed byte Data[128]; // inline buffer, not a managed array
}

unsafe class FixedDemo
{
  static void Main()
  {
    byte[] managed = new byte[4] { 1, 2, 3, 4 };
    fixed (byte* p = managed)
    {
      for (int i = 0; i < 4; i++) *(p + i) = (byte)(*(p + i) * 2);
    }
  }
}

Pointers and pinning

Pointers in C# use T* syntax and require an unsafe context. You can dereference with *, index with [], and do arithmetic only in unsafe code. Pinning ensures the garbage collector does not relocate an object while a native function uses its address.

Pointer types, conversions, and arithmetic

Pointer conversions must be explicit. Cast between pointer types with care and only when the underlying representation matches. Arithmetic respects the size of the pointed-to type in bytes.

unsafe class PtrDemo
{
  static void Main()
  {
    int value = 42;
    int* p = &value;
    byte* b = (byte*)p;           // explicit cast
    *(p) = 100;
    Console.WriteLine(*p);
    Console.WriteLine(*(b + 0));  // low byte view
  }
}

Pinning with fixed and GCHandle

fixed pins for the duration of the statement. For longer lifetimes, use GCHandle.Alloc(obj, GCHandleType.Pinned) and free it when finished. Do not leave objects pinned unnecessarily because it can fragment the heap.

using System;
using System.Runtime.InteropServices;

unsafe class PinDemo
{
  static void Main()
  {
    var arr = new byte[1024];
    var handle = GCHandle.Alloc(arr, GCHandleType.Pinned);
    try
    {
      byte* ptr = (byte*)handle.AddrOfPinnedObject();
      for (int i = 0; i < 8; i++) ptr[i] = (byte)i;
      Console.WriteLine(arr[7]);
    }
    finally
    {
      handle.Free();
    }
  }
}

Marshal types and layouts

Interop requires that managed types match native layouts. Use StructLayout, FieldOffset, and MarshalAs to control representation. Prefer SafeHandle for native resources; it wraps a native handle and participates in reliable finalization. Strings require careful treatment because native code may expect ANSI, UTF-16, or UTF-8.

Struct layout and packing

By default, structs use sequential layout that follows field order and natural packing. You can request explicit layout to match bit-level or byte-level contracts. Choose a CharSet when the struct contains characters.

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct Person
{
  public int Id;
  public double Balance;
  [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
  public string Name;
}

[StructLayout(LayoutKind.Explicit)]
struct Color32
{
  [FieldOffset(0)] public uint Value;
  [FieldOffset(0)] public byte R;
  [FieldOffset(1)] public byte G;
  [FieldOffset(2)] public byte B;
  [FieldOffset(3)] public byte A;
}

Strings, buffers, and UnmanagedType hints

For input strings prefer ReadOnlySpan<byte> or ReadOnlySpan<char> with [LibraryImport] customizations when possible. Otherwise use MarshalAs with the appropriate UnmanagedType. For output buffers use StringBuilder or explicit buffers with length arguments, which aligns with many C APIs.

Native expectation C# parameter Marshal guidance
UTF-16 wchar_t* string / char* CharSet.Unicode or UnmanagedType.LPWStr
ANSI char* string / byte* CharSet.Ansi or UnmanagedType.LPStr
UTF-8 char* byte* / ReadOnlySpan<byte> Pass bytes explicitly or use UTF-8 custom marshalling
Fixed inline string char name[32] string field [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
Out buffer StringBuilder / span Provide capacity and length parameter when required

Handles, ownership, and SafeHandle

Wrapping native handles with SafeHandle prevents leaks and adds reliability. Define a subclass that knows how to release the handle and use it in P/Invoke signatures rather than IntPtr when you own the handle.

using System;
using System.Runtime.InteropServices;

sealed class FileHandle : SafeHandle
{
  public FileHandle() : base(IntPtr.Zero, ownsHandle: true) { }
  public override bool IsInvalid => handle == IntPtr.Zero;

  protected override bool ReleaseHandle()
  {
    return CloseHandle(handle);
  }

  [DllImport("kernel32", SetLastError = true)]
  private static extern bool CloseHandle(IntPtr hObject);
}
💡 Prefer SafeHandle in APIs you expose from managed code. It communicates ownership and integrates with the garbage collector and constrained execution regions.

When to consider unsafe constructs

Unsafe code can remove bounds checks and enable pointer arithmetic. Use it when you have a proven hotspot where Span<T>, MemoryMarshal, and vectorized intrinsics are insufficient, or when a native API requires a raw pointer. Keep unsafe regions small, isolate them in well-named helpers, and validate preconditions at boundaries.

⚠️ Unsafe is not inherently faster. The just-in-time compiler can already remove many checks. Measure with a profiler and benchmarks before and after any change, then keep the simpler approach when performance is equivalent.

When security or maintainability is a priority, prefer safe abstractions. Many interop needs are satisfied by Span<T>, ReadOnlySpan<T>, and blittable structs with careful layout. Only choose pointers and pinning when a contract demands a stable address or raw memory view.

Testing and verifying interop

Interop tests should run on each supported platform and architecture. Validate data layout, calling conventions, lifetime rules, and error handling. Assert on Marshal.GetLastWin32Error() when you opt into SetLastError. Use smoke tests that load the library and call simple functions, then add property-based tests for buffer sizes and edge values.

Platform-aware unit tests

Conditionally include tests per platform, and ensure you load the correct library name. Use NativeLibrary.TryLoad for probing when appropriate, then call a simple exported function to validate wiring before running larger suites.

using System;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

public class InteropTests
{
  [SupportedOSPlatform("windows")]
  public void Can_Call_GetTickCount()
  {
#if WINDOWS
    uint ms = Native.GetTickCount();
    if (ms == 0) throw new Exception("Unexpected zero tick count.");
#endif
  }

  public void Can_Probe_Library()
  {
    if (NativeLibrary.TryLoad("libc", out var handle))
    {
      NativeLibrary.Free(handle);
    }
    else
    {
      throw new DllNotFoundException("libc not found … check platform image and rids");
    }
  }
}

Validating layout and marshalling

At runtime you can check sizes and offsets to ensure a managed struct matches the native contract. Combine this with small native fixtures in your repository that report sizeof results for the same structs to catch divergence early.

using System;
using System.Runtime.InteropServices;

static class LayoutChecks
{
  static void Main()
  {
    int managedSize = Marshal.SizeOf<Person>();
    Console.WriteLine($"Person size: {managedSize}");
    // Compare against native sizeof(Person) from a fixture binary …
  }
}
💡 Automate interop tests in CI on x64 Windows, Linux, and macOS. Add at least one arm64 run when you ship to Apple Silicon or ARM servers.